Skip to content

Commit

Permalink
SVG viewbox support (2.0) (#150)
Browse files Browse the repository at this point in the history
* Add x and y to SVG

* initialise to zero, because not mandatory

* Update translations in fitToViewer

* bugfix in setting new SVG x and y

* Set default value for SVG x and y

* Merge PR #88 from @kheyse-oqton with current version

* stylistic changes

* Update fit to viewer as switch statement

More explicit handling of the translations as a function of alignment options

* Cleaning up of the constructor and componentDidUpdate functions

* using destructured values in Miniature

* small scaling bugfix

* small scale fix: scaling should be with viewer scaleLevel, not scaleY

* adds ViewboxStory

* minor ViewboxStory fix

* rename SVGViewBoxXY to SVGMinXY

* in order to reuse the standard name as defined in SVG RFC https://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute, please rename SVGViewBoxX and SVGViewBoxY with SVGMinX and SVGMinY
#150 (comment)

* Move viewbox parser to separate file

* please move the ViewBox parser in a new file called utils/ViewBoxParser.js

#150 (comment)

* make ViewBox parser W3C compliant

This should do the trick and is more readable than a regex, IMO

#150 (comment)

* replace `withViewBox` by `viewBox`

#150 (comment)
  • Loading branch information
TimVanMourik authored and chrvadala committed Sep 17, 2019
1 parent 334de3a commit c69b899
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 48 deletions.
10 changes: 7 additions & 3 deletions src/features/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
* Obtain default value
* @returns {Object}
*/
export function getDefaultValue(viewerWidth, viewerHeight, SVGWidth, SVGHeight, scaleFactorMin, scaleFactorMax) {
export function getDefaultValue(viewerWidth, viewerHeight, SVGMinX, SVGMinY, SVGWidth, SVGHeight, scaleFactorMin, scaleFactorMax) {
return set({}, {
...identity(),
version: 2,
Expand All @@ -23,6 +23,8 @@ export function getDefaultValue(viewerWidth, viewerHeight, SVGWidth, SVGHeight,
prePinchMode: null,
viewerWidth,
viewerHeight,
SVGMinX,
SVGMinY,
SVGWidth,
SVGHeight,
scaleFactorMin,
Expand Down Expand Up @@ -112,12 +114,14 @@ export function setViewerSize(value, viewerWidth, viewerHeight) {
/**
*
* @param value
* @param SVGMinX
* @param SVGMinY
* @param SVGWidth
* @param SVGHeight
* @returns {Object}
*/
export function setSVGSize(value, SVGWidth, SVGHeight) {
return set(value, {SVGWidth, SVGHeight});
export function setSVGViewBox(value, SVGMinX, SVGMinY, SVGWidth, SVGHeight) {
return set(value, {SVGMinX, SVGMinY, SVGWidth, SVGHeight});
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/features/pan.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export function pan(value, SVGDeltaX, SVGDeltaY, panLimit = undefined) {
// apply pan limits
if (panLimit) {
let [{x: x1, y: y1}, {x: x2, y: y2}] = applyToPoints(matrix, [
{x: panLimit, y: panLimit},
{x: value.SVGWidth - panLimit, y: value.SVGHeight - panLimit}
{x: value.SVGMinX + panLimit, y: value.SVGMinY + panLimit},
{x: value.SVGMinX + value.SVGWidth - panLimit, y: value.SVGMinY + value.SVGHeight - panLimit}
]);

//x limit
Expand Down
42 changes: 28 additions & 14 deletions src/features/zoom.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,35 +96,49 @@ export function fitSelection(value, selectionSVGPointX, selectionSVGPointY, sele
}

export function fitToViewer(value, SVGAlignX=ALIGN_LEFT, SVGAlignY=ALIGN_TOP) {
let {viewerWidth, viewerHeight, SVGWidth, SVGHeight} = value;
let {viewerWidth, viewerHeight, SVGMinX, SVGMinY, SVGWidth, SVGHeight} = value;

let scaleX = viewerWidth / SVGWidth;
let scaleY = viewerHeight / SVGHeight;
let scaleLevel = Math.min(scaleX, scaleY);

const scaleMatrix = scale(scaleLevel, scaleLevel);
let translationMatrix = translate(0, 0);

let translateX = -SVGMinX * scaleX;
let translateY = -SVGMinY * scaleY;

// after fitting, SVG and the viewer will match in width (1) or in height (2)
if (scaleX < scaleY) {
//(1) match in width, meaning scaled SVGHeight <= viewerHeight
let remainderY = viewerHeight - scaleX * SVGHeight;

if (SVGAlignY === ALIGN_CENTER)
translationMatrix = translate(0, Math.round(remainderY / 2));
if (SVGAlignY === ALIGN_BOTTOM)
translationMatrix = translate(0, remainderY);
}
else {
switch(SVGAlignY) {
case ALIGN_TOP:
translateY = -SVGMinY * scaleLevel;
break;
case ALIGN_CENTER:
translateY = Math.round(remainderY / 2) - SVGMinY * scaleLevel;
break;
case ALIGN_BOTTOM:
translateY = remainderY - SVGMinY * scaleLevel;
break;
}
} else {
//(2) match in height, meaning scaled SVGWidth <= viewerWidth
let remainderX = viewerWidth - scaleY * SVGWidth;

if (SVGAlignX === ALIGN_CENTER)
translationMatrix = translate(Math.round(remainderX / 2), 0);
if (SVGAlignX === ALIGN_RIGHT)
translationMatrix = translate(remainderX, 0);
switch(SVGAlignX) {
case ALIGN_LEFT:
translateX = -SVGMinX * scaleLevel;
break;
case ALIGN_CENTER:
translateX = Math.round(remainderX / 2) - SVGMinX * scaleLevel;
break;
case ALIGN_RIGHT:
translateX = remainderX - SVGMinX * scaleLevel;
break;
}
}

const translationMatrix = translate(translateX, translateY);
const matrix = transform(
translationMatrix, //2
scaleMatrix //1
Expand Down
12 changes: 7 additions & 5 deletions src/ui-miniature/miniature-mask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@ import RandomUID from "../utils/RandomUID";

const prefixID = 'react-svg-pan-zoom_miniature'

function MiniatureMask({SVGWidth, SVGHeight, x1, y1, x2, y2, zoomToFit, _uid}) {
let maskID = `${prefixID}_mask_${_uid}`
function MiniatureMask({SVGMinX, SVGMinY, SVGWidth, SVGHeight, x1, y1, x2, y2, zoomToFit, _uid}) {
const maskID = `${prefixID}_mask_${_uid}`

return (
<g>
<defs>
<mask id={maskID}>
<rect x="0" y="0" width={SVGWidth} height={SVGHeight} fill="#ffffff"/>
<rect x={SVGMinX} y={SVGMinY} width={SVGWidth} height={SVGHeight} fill="#ffffff"/>
<rect x={x1} y={y1} width={x2 - x1} height={y2 - y1}/>
</mask>
</defs>

<rect
x="0"
y="0"
x={SVGMinX}
y={SVGMinY}
width={SVGWidth}
height={SVGHeight}
style={{
Expand All @@ -34,6 +34,8 @@ function MiniatureMask({SVGWidth, SVGHeight, x1, y1, x2, y2, zoomToFit, _uid}) {
MiniatureMask.propTypes = {
SVGWidth: PropTypes.number.isRequired,
SVGHeight: PropTypes.number.isRequired,
SVGMinX: PropTypes.number.isRequired,
SVGMinY: PropTypes.number.isRequired,
x1: PropTypes.number.isRequired,
y1: PropTypes.number.isRequired,
x2: PropTypes.number.isRequired,
Expand Down
16 changes: 9 additions & 7 deletions src/ui-miniature/miniature.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const {min, max} = Math;
export default function Miniature(props) {

let {value, onChangeValue, children, position, background, SVGBackground, width: miniatureWidth, height: miniatureHeight} = props;
let {SVGWidth, SVGHeight, viewerWidth, viewerHeight} = value;
let {SVGMinX, SVGMinY, SVGWidth, SVGHeight, viewerWidth, viewerHeight} = value;

let ratio = SVGHeight / SVGWidth;

Expand Down Expand Up @@ -45,8 +45,8 @@ export default function Miniature(props) {
};

let centerTranslation = ratio >= 1
? `translate(${(miniatureWidth - (SVGWidth * zoomToFit)) / 2}, 0)`
: `translate(0, ${(miniatureHeight - (SVGHeight * zoomToFit)) / 2})`;
? `translate(${(miniatureWidth - (SVGWidth * zoomToFit)) / 2 - SVGMinX * zoomToFit}, ${ - SVGMinY * zoomToFit})`
: `translate(${ - SVGMinX * zoomToFit}, ${(miniatureHeight - (SVGHeight * zoomToFit)) / 2 - SVGMinY * zoomToFit})`;

return (
<div role="navigation" style={style}>
Expand All @@ -59,16 +59,18 @@ export default function Miniature(props) {

<rect
fill={SVGBackground}
x={0}
y={0}
width={value.SVGWidth}
height={value.SVGHeight}/>
x={SVGMinX}
y={SVGMinY}
width={SVGWidth}
height={SVGHeight}/>

{children}

<MiniatureMask
SVGWidth={SVGWidth}
SVGHeight={SVGHeight}
SVGMinX={SVGMinX}
SVGMinY={SVGMinY}
x1={x1}
y1={y1}
x2={x2}
Expand Down
7 changes: 7 additions & 0 deletions src/utils/ViewBoxParser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function parseViewBox(viewBoxString) {
// viewBox specs: https://www.w3.org/TR/SVG/coords.html#ViewBoxAttribute
return viewBoxString
.split(/[ ,]/) // split optional comma
.filter(Boolean) // remove empty strings
.map(Number); // cast to Number
}
59 changes: 42 additions & 17 deletions src/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
isValueValid,
reset,
setPointOnViewerCenter,
setSVGSize,
setSVGViewBox,
setViewerSize,
setZoomLevels
} from './features/common';
Expand All @@ -23,6 +23,7 @@ import {
onMouseUp,
onWheel
} from './features/interactions';
import parseViewBox from './utils/ViewBoxParser';
import {onTouchCancel, onTouchEnd, onTouchMove, onTouchStart} from './features/interactions-touch';

import {fitSelection, fitToViewer, zoom, zoomOnViewerCenter} from './features/zoom';
Expand Down Expand Up @@ -63,14 +64,22 @@ export default class ReactSVGPanZoom extends React.Component {

constructor(props, context) {
const {value, width: viewerWidth, height: viewerHeight, scaleFactorMin, scaleFactorMax, children} = props;
const {width: SVGWidth, height: SVGHeight} = children.props;
const {viewBox: SVGViewBox} = children.props;
let defaultValue;
if (SVGViewBox) {
const [SVGMinX, SVGMinY, SVGWidth, SVGHeight] = parseViewBox(SVGViewBox);
defaultValue = getDefaultValue(viewerWidth, viewerHeight, SVGMinX, SVGMinY, SVGWidth, SVGHeight, scaleFactorMin, scaleFactorMax)
} else {
const {width: SVGWidth, height: SVGHeight} = children.props;
defaultValue = getDefaultValue(viewerWidth, viewerHeight, 0, 0, SVGWidth, SVGHeight, scaleFactorMin, scaleFactorMax)
}

super(props, context);
this.ViewerDOM = null;
this.state = {
pointerX: null,
pointerY: null,
defaultValue: getDefaultValue(viewerWidth, viewerHeight, SVGWidth, SVGHeight, scaleFactorMin, scaleFactorMax)
defaultValue
}
this.autoPanLoop = this.autoPanLoop.bind(this);

Expand All @@ -91,6 +100,26 @@ export default class ReactSVGPanZoom extends React.Component {
printMigrationTipsRelatedToProps(props)
}

// This block checks the size of the SVG
const {viewBox: SVGViewBox} = props.children.props;
if (SVGViewBox) {
// if the viewBox prop is specified
const [x, y, width, height] = parseViewBox(SVGViewBox);

if(value.SVGMinX !== x || value.SVGMinY !== y || value.SVGWidth !== width || value.SVGHeight !== height) {
nextValue = setSVGViewBox(nextValue, x, y, width, height);
needUpdate = true;
}
} else {
// if the width and height props are specified
const {width: SVGWidth, height: SVGHeight} = props.children.props;
if (value.SVGWidth !== SVGWidth || value.SVGHeight !== SVGHeight) {
nextValue = setSVGViewBox(nextValue, 0, 0, SVGWidth, SVGHeight);
needUpdate = true;
}
}

// This block checks the size of the viewer
if (
prevProps.width !== props.width ||
prevProps.height !== props.height
Expand All @@ -99,16 +128,7 @@ export default class ReactSVGPanZoom extends React.Component {
needUpdate = true;
}

let {width: SVGWidth, height: SVGHeight} = props.children.props;
let {width: prevSVGWidth, height: prevSVGHeight} = prevProps.children.props;
if (
prevSVGWidth !== SVGWidth ||
prevSVGHeight !== SVGHeight
) {
nextValue = setSVGSize(nextValue, SVGWidth, SVGHeight);
needUpdate = true;
}

// This blocks checks the scale factors
if (
prevProps.scaleFactorMin !== props.scaleFactorMin ||
prevProps.scaleFactorMax !== props.scaleFactorMax
Expand Down Expand Up @@ -361,8 +381,8 @@ export default class ReactSVGPanZoom extends React.Component {
<rect
fill={this.props.SVGBackground}
style={this.props.SVGStyle}
x={0}
y={0}
x={value.SVGMinX || 0}
y={value.SVGMinY || 0}
width={value.SVGWidth}
height={value.SVGHeight}/>
<g>
Expand Down Expand Up @@ -444,6 +464,8 @@ ReactSVGPanZoom.propTypes = {
f: PropTypes.number.isRequired,
viewerWidth: PropTypes.number.isRequired,
viewerHeight: PropTypes.number.isRequired,
SVGMinX: PropTypes.number.isRequired,
SVGMinY: PropTypes.number.isRequired,
SVGWidth: PropTypes.number.isRequired,
SVGHeight: PropTypes.number.isRequired,
startX: PropTypes.number,
Expand Down Expand Up @@ -591,8 +613,11 @@ ReactSVGPanZoom.propTypes = {
' `' + types.join('`, `') + '`.'
);
}
if (!prop.props.hasOwnProperty('width') || !prop.props.hasOwnProperty('height')) {
return new Error('SVG should have props `width` and `height`');
if (
(!prop.props.hasOwnProperty('width') || !prop.props.hasOwnProperty('height')) &&
(!prop.props.hasOwnProperty('viewBox'))
) {
return new Error('SVG should have props `width` and `height` or `viewBox`');
}

}
Expand Down
55 changes: 55 additions & 0 deletions storybook/stories/ViewboxStory.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, {StrictMode} from 'react';
import {action} from '@storybook/addon-actions';
import {noArgsDecorator, viewerMouseEventDecorator} from './actions-decorator';

import {UncontrolledReactSVGPanZoom} from '../../src/index';
import {boolean} from "@storybook/addon-knobs";

export default class ViewboxStory extends React.Component {
constructor(props) {
super(props)
this.Viewer = null;
}

componentDidMount() {
this.Viewer.fitToViewer();
}

render() {
return (
<StrictMode>

<UncontrolledReactSVGPanZoom
width={400} height={400}

ref={Viewer => this.Viewer = Viewer}

onClick={viewerMouseEventDecorator('onClick')}

onChangeValue={noArgsDecorator('onChangeValue')}
onChangeTool={action('onChangeTool')}

detectAutoPan={boolean('detectAutoPan', false)}
detectWheel={boolean('detectWheel', false)}
detectPinchGesture={boolean('detectPinchGesture', false)}
>

<svg
width={100} height={100}
withViewBox="10 10 80 80"
>

<rect x="20" y="20" width="60" height="60" fill="yellow"/>
<circle cx="20" cy="20" r="4" fill="red"/>
<circle cx="80" cy="80" r="4" fill="red"/>

<circle cx="0" cy="0" r="4" fill="blue"/>
<circle cx="100" cy="100" r="4" fill="blue"/>

<circle cx="50" cy="50" r="2" fill="black"/>
</svg>
</UncontrolledReactSVGPanZoom>
</StrictMode>
)
}
}
2 changes: 2 additions & 0 deletions storybook/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import AutosizerViewer from './AutosizerViewer'
import DifferentSizesStory from './DifferentSizesStory';
import RuntimeResizeStory from "./RuntimeResizeStory";
import UncontrolledViewerStory from "./UncontrolledViewerStory";
import ViewboxStory from './ViewboxStory'

storiesOf('React SVG Pan Zoom', module)
.addDecorator(withKnobs)
Expand All @@ -20,5 +21,6 @@ storiesOf('React SVG Pan Zoom', module)
.add('Autosizer viewer', () => <AutosizerViewer />)
.add('Different Sizes', () => <DifferentSizesStory />)
.add('Runtime Resize', () => <RuntimeResizeStory />)
.add('Viewbox prop', () => <ViewboxStory />)


0 comments on commit c69b899

Please sign in to comment.