diff --git a/.gitignore b/.gitignore index 3848792e..945e61b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store dist/ npm-debug.log -node_modules \ No newline at end of file +node_modules +.next \ No newline at end of file diff --git a/README.md b/README.md index 127b7829..11142bfd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,23 @@ # react-image-magnify -A React component displaying side by side enlarged image view, with tinted control-image mask. +A React component for desktop and touch environments. -Intended for desktop, but will be updated with touch experience soon. +Desktop displays a side by side enlarged image view, with tinted control-image mask. +Supports delaying hover and hover-off, which may help reduce unintentional triggering. -Supports arbitrary image sizes. Scales magnification automatically. +Touch displays an in place enlarged image view. +Supports press to pan. Does not interfere with scrolling. -Supports delaying hover and hover-off, which can help reduce unintentional triggering. +Supports arbitrary image sizes. Scales magnification automatically. ## Demo -[Basic demo](http://www.webpackbin.com/N18FshAaW) +[Desktop](https://react-image-magnify-egeoxscwwk.now.sh/) + +[Touch](https://goo.gl/A6DZog) -See ReactImageMagnify.js tab. +Touch Demo + +https://goo.gl/A6DZog ## Installation @@ -20,6 +26,7 @@ npm install --save react-image-magnify ``` ## Usage +### Desktop ```JSX import ReactImageMagnify from 'react-image-magnify'; @@ -40,6 +47,27 @@ import ReactImageMagnify from 'react-image-magnify'; }}/> ... ``` +### Touch + +```JavaScript +import ReactImageMagnifyTouch from 'react-image-magnify'; +... + +... +``` ## Props API @@ -53,7 +81,7 @@ import ReactImageMagnify from 'react-image-magnify'; | `hoverOffDelayInMs` | Number | No | 150 | Milliseconds to delay hover-off trigger. | | `fadeDurationInMs` | Number | No | 300 | Milliseconds duration of magnified image fade in/fade out. | | `imageStyle` | Object | No | | Style applied to small image element. | -| `lensStyle` | Object | No | | Style applied to tinted lens element. | +| `lensStyle` | Object | No | | Style applied to tinted lens element. Desktop only | | `enlargedImageContainerStyle` | Object | No | | Style applied to enlarged image container element. | | `enlargedImageStyle` | Object | No | | Style applied to enlarged image element. | diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 00000000..b404b4f0 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,20 @@ +{ + "name": "react-image-magnify", + "version": "1.0.0", + "description": "react-image-magnify example and demo", + "main": "index.js", + "author": "", + "license": "ISC", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^1.1.0", + "lodash.assign": "^4.2.0", + "lodash.clamp": "^4.0.3", + "react-cursor-position": "^1.1.0", + "react-hover-observer": "^1.1.0" + } +} diff --git a/demo/pages/index.js b/demo/pages/index.js new file mode 100644 index 00000000..944d0c8d --- /dev/null +++ b/demo/pages/index.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react'; +import ReactImageMagnify from '../src/ReactImageMagnify'; + +class App extends Component { + render() { + return ( +
+
+
+ +
+ ); + } +} + +export default App; diff --git a/demo/pages/touch.js b/demo/pages/touch.js new file mode 100644 index 00000000..5bc1ef97 --- /dev/null +++ b/demo/pages/touch.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; +import ReactImageMagnifyTouch from '../src/ReactImageMagnifyTouch'; + +class App extends Component { + constructor(props) { + super(props); + this.state = { + imageWidth: 0, + imageHeight: 0 + }; + } + + componentDidMount() { + const rect = document.documentElement.getBoundingClientRect(); + const screenWidth = rect.width; + const imageWidth = screenWidth - 300; + + this.setState({ + imageWidth, + imageHeight: imageWidth + (imageWidth / 2) + }); + } + + render() { + return ( +
+ +
+

Press (long touch) image to magnify. Pan (drag) to traverse image.

+

Note the page can be scrolled when touch begins on image.

+
+
+ ); + } +} + +export default App; diff --git a/demo/src/EnlargedImage.js b/demo/src/EnlargedImage.js new file mode 100644 index 00000000..4aef92f8 --- /dev/null +++ b/demo/src/EnlargedImage.js @@ -0,0 +1,170 @@ +import React, { PropTypes } from 'react'; +import clamp from 'lodash.clamp'; +import { Image } from './ReactImageMagnify'; + +export const Point = PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired +}); + +export const ImageShape = PropTypes.shape({ + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired +}); + +export default React.createClass({ + + displayName: 'EnlargedImage', + + getInitialState() { + return { + isTransitionEntering: false, + isTransitionActive: false, + isTransitionLeaving: false, + isTransitionDone: false + }; + }, + + getDefaultProps() { + return { + fadeDurationInMs: 0 + }; + }, + + propTypes: { + containerClassName: PropTypes.string, + containerStyle: PropTypes.object, + cursorOffset: Point, + cursorPosition: Point, + fadeDurationInMs: PropTypes.number, + imageClassName: PropTypes.string, + imageStyle: PropTypes.object, + isHovering: PropTypes.bool, + largeImage: ImageShape, + smallImage: ImageShape + }, + + componentWillReceiveProps(nextProps) { + const { isHovering } = nextProps; + + if (isHovering) { + this.setState({ + isTransitionEntering: true + }); + + setTimeout(() => { + this.setState({ + isTransitionEntering: false, + isTransitionActive: true + }); + }, 0); + } else { + this.setState({ + isTransitionActive: false, + isTransitionLeaving: true + }); + + setTimeout(() => { + this.setState({ + isTransitionLeaving: false, + isTransitionDone: true + }); + }, this.props.fadeDurationInMs); + } + }, + + render() { + const { + containerClassName, + containerStyle, + cursorOffset, + cursorPosition, + fadeDurationInMs, + imageClassName, + imageStyle, + isHovering, + largeImage, + smallImage, + } = this.props; + + const { + isTransitionEntering, + isTransitionActive, + isTransitionLeaving + } = this.state; + + const offsetRatio = { + x: largeImage.width / smallImage.width, + y: largeImage.height / smallImage.height + }; + + const differentiatedImageCoordinates = { + x: (Math.round((cursorPosition.x - cursorOffset.x) * offsetRatio.x) * -1), + y: (Math.round((cursorPosition.y - cursorOffset.y) * offsetRatio.y) * -1) + }; + + const minCoordinates = { + x: ((largeImage.width - smallImage.width) * -1), + y: ((largeImage.height - smallImage.height) * -1) + }; + + const maxCoordinate = 0; + + const imageCoordinates = { + x: clamp(differentiatedImageCoordinates.x, minCoordinates.x, maxCoordinate), + y: clamp(differentiatedImageCoordinates.y, minCoordinates.y, maxCoordinate) + }; + + let isVisible; + if (isTransitionEntering || isTransitionActive || isTransitionLeaving) { + isVisible = true; + } else { + isVisible = false; + } + + const defaultContainerStyle = { + marginLeft: '10px', + position: 'absolute', + left: '100%', + top: '0px', + border: '1px solid #d6d6d6', + overflow: 'hidden' + }; + + const computedContainerStyle = { + width: smallImage.width, + height: smallImage.height, + opacity: this.state.isTransitionActive ? 1 : 0, + transition: `opacity ${fadeDurationInMs}ms ease-in` + }; + + const translate = `translate(${imageCoordinates.x}px, ${imageCoordinates.y}px)`; + + const computedImageStyle = { + width: largeImage.width, + height: largeImage.height, + transform: translate, + WebkitTransform: translate, + msTransform: translate + }; + + const component = ( +
+ +
+ ); + + return isVisible ? component : null; + } +}); diff --git a/demo/src/Lens.js b/demo/src/Lens.js new file mode 100644 index 00000000..782105f1 --- /dev/null +++ b/demo/src/Lens.js @@ -0,0 +1,49 @@ +import React, { PropTypes } from 'react'; + +const Lens = (props) => { + const { + fadeDurationInMs, + isHovering, + style, + translateX, + translateY + } = props; + const translate = `translate(${translateX}px, ${translateY}px)`; + const computedStyle = { + position: 'absolute', + transform: translate, + WebkitTransform: translate, + msTransform: translate, + opacity: isHovering ? 1 : 0, + transition: `opacity ${fadeDurationInMs}ms ease-in` + }; + const defaultStyle = { + width: 'auto', + height: 'auto', + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto', + display: 'block' + }; + + return
; +} + +Lens.propTypes = { + style: PropTypes.object, + fadeDurationInMs: PropTypes.number, + isHovering: PropTypes.bool, + translateX: PropTypes.number, + translateY: PropTypes.number, + userStyle: PropTypes.object +}; + +Lens.defaultProps = { + isHovering: false, + fadeDurationInMs: 0, + translateX: 0, + translateY: 0 +}; + +export default Lens; diff --git a/demo/src/LensBottom.js b/demo/src/LensBottom.js new file mode 100644 index 00000000..37018da5 --- /dev/null +++ b/demo/src/LensBottom.js @@ -0,0 +1,35 @@ +import React from 'react'; +import clamp from 'lodash.clamp'; +import Lens from './Lens'; + +const LensBottom = ({ + cursorOffset, + cursorPosition, + fadeDurationInMs, + isHovering, + smallImage, + style +}) => { + + const maxHeight = smallImage.height - (cursorOffset.y * 2); + const height = clamp(smallImage.height - cursorPosition.y - cursorOffset.y, 0, maxHeight); + const computedStyle = { + height: `${height}px`, + width: '100%', + bottom: '0px' + }; + + return ( + + ); +}; + +export default LensBottom; diff --git a/demo/src/LensLeft.js b/demo/src/LensLeft.js new file mode 100644 index 00000000..b8f14209 --- /dev/null +++ b/demo/src/LensLeft.js @@ -0,0 +1,36 @@ +import React from 'react'; +import clamp from 'lodash.clamp'; +import Lens from './Lens'; + +const LensLeft = ({ + cursorOffset, + cursorPosition, + fadeDurationInMs, + isHovering, + smallImage, + style +}) => { + + const height = cursorOffset.y * 2; + const maxHeight = smallImage.height - height; + const maxWidth = smallImage.width - (cursorOffset.x * 2); + const width = clamp(cursorPosition.x - cursorOffset.x, 0, maxWidth); + const translateY = clamp(cursorPosition.y - cursorOffset.y, 0, maxHeight); + const computedStyle = { + height: `${height}px`, + width: `${width}px`, + top: '0px', + left: '0px' + }; + + return ( + + ); +}; + +export default LensLeft; diff --git a/demo/src/LensRight.js b/demo/src/LensRight.js new file mode 100644 index 00000000..f9684949 --- /dev/null +++ b/demo/src/LensRight.js @@ -0,0 +1,36 @@ +import React from 'react'; +import clamp from 'lodash.clamp'; +import Lens from './Lens'; + +const LensRight = ({ + cursorOffset, + cursorPosition, + fadeDurationInMs, + isHovering, + smallImage, + style +}) => { + + const height = cursorOffset.y * 2; + const maxHeight = smallImage.height - height; + const maxWidth = smallImage.width - (cursorOffset.x * 2); + const width = clamp(smallImage.width - cursorPosition.x - cursorOffset.x, 0, maxWidth); + const translateY = clamp(Math.round(cursorPosition.y - cursorOffset.y), 0, maxHeight); + const computedStyle = { + height: `${height}px`, + width: `${width}px`, + top: '0px', + right: '0px' + }; + + return ( + + ); +}; + +export default LensRight; diff --git a/demo/src/LensTop.js b/demo/src/LensTop.js new file mode 100644 index 00000000..a60dff63 --- /dev/null +++ b/demo/src/LensTop.js @@ -0,0 +1,31 @@ +import React from 'react'; +import clamp from 'lodash.clamp'; +import Lens from './Lens'; + +const LensTop = ({ + cursorOffset, + cursorPosition, + fadeDurationInMs, + isHovering, + smallImage, + style +}) => { + + const maxHeight = smallImage.height - (cursorOffset.y * 2); + const height = clamp(cursorPosition.y - cursorOffset.y, 0, maxHeight); + const computedStyle = { + height: `${height}px`, + width: '100%', + top: '0px' + }; + + return ( + + ); +}; + +export default LensTop; diff --git a/demo/src/ReactImageMagnify.js b/demo/src/ReactImageMagnify.js new file mode 100644 index 00000000..04784e33 --- /dev/null +++ b/demo/src/ReactImageMagnify.js @@ -0,0 +1,124 @@ +import React, { PropTypes } from 'react'; +import ReactCursorPosition from 'react-cursor-position'; +import ReactHoverObserver from 'react-hover-observer'; +import LensTop from './LensTop'; +import LensLeft from './LensLeft'; +import LensRight from './LensRight'; +import LensBottom from './LensBottom'; +import EnlargedImage from './EnlargedImage'; + +const ReactImageMagnify = ({ + className, + enlargedImageContainerStyle, + enlargedImageStyle, + fadeDurationInMs, + hoverDelayInMs, + hoverOffDelayInMs, + imageStyle, + largeImage, + lensStyle, + smallImage, + style +}) => { + + const cursorOffset = { + x: Math.round(((smallImage.width / largeImage.width) * smallImage.width) / 2), + y: Math.round(((smallImage.height / largeImage.height) * smallImage.height) / 2) + }; + const defaultLensStyle = { backgroundColor: 'rgba(0,0,0,.4)' }; + const compositLensStyle = Object.assign({}, defaultLensStyle, lensStyle); + + return ( + + setIsHovering(), + onMouseLeave: ({ unsetIsHovering }) => unsetIsHovering(), + onMouseOver: ({ e, unsetIsHovering }) => { + if (e.target.getAttribute('data-hover') === 'false') { + unsetIsHovering(); + } + } + }}> + + + + + + + + + ); +} + +export const ImageShape = PropTypes.shape({ + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired +}); + +ReactImageMagnify.propTypes = { + className: PropTypes.string, + enlargedImageContainerStyle: PropTypes.object, + enlargedImageStyle: PropTypes.object, + fadeDurationInMs: PropTypes.number, + hoverDelayInMs: PropTypes.number, + hoverOffDelayInMs: PropTypes.number, + imageStyle: PropTypes.object, + largeImage: ImageShape, + lensStyle: PropTypes.object, + smallImage: ImageShape, + style: PropTypes.object +}; + +ReactImageMagnify.defaultProps = { + fadeDurationInMs: 300, + hoverDelayInMs: 250, + hoverOffDelayInMs: 150 +}; + +export default ReactImageMagnify; diff --git a/demo/src/ReactImageMagnifyTouch.js b/demo/src/ReactImageMagnifyTouch.js new file mode 100644 index 00000000..96d56821 --- /dev/null +++ b/demo/src/ReactImageMagnifyTouch.js @@ -0,0 +1,89 @@ +import React, { PropTypes } from 'react'; +import ReactHoverObserver from 'react-hover-observer'; +import ReactTouchPosition from './ReactTouchPosition'; +import LensTop from './LensTop'; +import LensLeft from './LensLeft'; +import LensRight from './LensRight'; +import LensBottom from './LensBottom'; +import EnlargedImage from './EnlargedImage'; + +const ReactImageMagnify = ({ + className, + enlargedImageContainerStyle, + enlargedImageStyle, + fadeDurationInMs, + hoverDelayInMs, + hoverOffDelayInMs, + imageStyle, + largeImage, + lensStyle, + smallImage, + style +}) => { + + const cursorOffset = { + x: Math.round(((smallImage.width / largeImage.width) * smallImage.width) / 2), + y: Math.round(((smallImage.height / largeImage.height) * smallImage.height) / 2) + }; + + return ( + + + + + ); +} + +export const ImageShape = PropTypes.shape({ + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired +}); + +ReactImageMagnify.propTypes = { + className: PropTypes.string, + enlargedImageContainerStyle: PropTypes.object, + enlargedImageStyle: PropTypes.object, + fadeDurationInMs: PropTypes.number, + hoverDelayInMs: PropTypes.number, + hoverOffDelayInMs: PropTypes.number, + imageStyle: PropTypes.object, + largeImage: ImageShape, + lensStyle: PropTypes.object, + smallImage: ImageShape, + style: PropTypes.object +}; + +ReactImageMagnify.defaultProps = { + fadeDurationInMs: 300, + hoverDelayInMs: 250, + hoverOffDelayInMs: 150 +}; + +export default ReactImageMagnify; diff --git a/demo/src/ReactTouchPosition.js b/demo/src/ReactTouchPosition.js new file mode 100644 index 00000000..00975815 --- /dev/null +++ b/demo/src/ReactTouchPosition.js @@ -0,0 +1,231 @@ +import React, { + Children, + cloneElement, + PropTypes +} from 'react'; +import assign from 'lodash.assign'; +import noop from 'lodash.noop'; +import omit from 'lodash.omit'; + +export default React.createClass({ + + displayName: 'TouchPosition', + + getInitialState() { + return { + isHovering: false, + isTouchOutside: false, + cursorPosition: { + x: 0, + y: 0 + } + }; + }, + + propTypes: { + className: PropTypes.string, + isActivatedOnTouch: PropTypes.bool, + onActivationChanged: PropTypes.func, + onPositionChanged: PropTypes.func, + pressDuration: PropTypes.number, + pressMoveThreshold: PropTypes.number, + shouldDecorateChildren: PropTypes.bool, + style: PropTypes.object + }, + + getDefaultProps() { + return { + isActivatedOnTouch: false, + onActivationChanged: noop, + onPositionChanged: noop, + pressDuration: 500, + pressMoveThreshold: 5, + shouldDecorateChildren: true + }; + }, + + //on every method a different approach is taken on arguments + onTouchStart(e) { + this.elementOffsetRect = this.getViewportRelativeElementRect(e.currentTarget); + this.setPosition(e); + + if (this.props.isActivatedOnTouch) { + e.preventDefault(); + + this.setState({ + isHovering: true + }); + + return; + } + + this.initPressEventCriteria(e.touches[0]); + + this.setPressEventTimer() + }, + + onTouchMove(e) { + this.setPressEventCriteria(e.touches[0]); + + if (!this.state.isHovering) { + return; + } + + this.setPosition(e); + + e.preventDefault(); + }, + + onTouchEnd() { + this.clearTimers(); + + this.setState({ + isHovering: false, + isTouchOutside: false + }); + + this.props.onActivationChanged({ isHovering: false }); + }, + + setPosition(e) { + const viewportRelativeTouchPosition = this.getViewportRelativeTouchPosition(e); + const elementOffsetRect = this.elementOffsetRect; + const cursorPosition = this.getElementRelativeTouchPosition(viewportRelativeTouchPosition, elementOffsetRect); + const isPositionOutside = this.getIsPositionOutside(viewportRelativeTouchPosition, elementOffsetRect); + + this.setState({ + cursorPosition, + isPositionOutside + }); + + this.props.onPositionChanged(Object.assign({ isPositionOutside }, cursorPosition)); + }, + + setPressEventTimer() { + this.pressDurationTimerId = setTimeout(() => { + if (Math.abs(this.currentElTop - this.initialElTop) < this.props.pressMoveThreshold) { + this.setState({ isHovering: true }); + this.props.onActivationChanged({ isHovering: true }); + } + }, this.props.pressDuration); + }, + + setPressEventCriteria(touch) { + if (!this.props.isActivatedOnTouch) { + if (!this.state.isHovering) { + this.currentElTop = touch.clientY; + } else { + this.initialElTop = touch.clientY; + } + } + }, + + initPressEventCriteria(touch) { + const top = touch.clientY; + this.initialElTop = top; + this.currentElTop = top; + }, + + getViewportRelativeElementRect(el) { + return el.getBoundingClientRect(); + }, + + getIsPositionOutside(viewportRelativeTouchPosition, elementOffsetRect) { + const { x: viewportRelativeTouchX, y: viewportRelativeTouchY } = viewportRelativeTouchPosition; + const { + top: offsetTop, + right: offsetRight, + bottom: offsetBottom, + left: offsetLeft + } = elementOffsetRect; + return ( + viewportRelativeTouchX < offsetLeft || + viewportRelativeTouchX > offsetRight || + viewportRelativeTouchY < offsetTop || + viewportRelativeTouchY > offsetBottom + ); + }, + + getViewportRelativeTouchPosition(event) { + const touch = event.touches[0]; + return { + x: touch.clientX, + y: touch.clientY + } + }, + + getElementRelativeTouchPosition(viewportRelativetouchPosition, elementOffsetRect) { + const { x: touchX, y: touchY } = viewportRelativetouchPosition; + const { left: offsetX, top: offsetY } = elementOffsetRect; + + return { + x: touchX - offsetX, + y: touchY - offsetY + }; + }, + + isReactComponent(reactElement) { + return typeof reactElement.type === 'function'; + }, + + shouldDecorateChild(child) { + return this.isReactComponent(child) && this.props.shouldDecorateChildren; + }, + + decorateChild(child, props) { + return cloneElement(child, props); + }, + + renderChildrenWithProps(children, props) { + return Children.map(children, (child) => { + return this.shouldDecorateChild(child) ? this.decorateChild(child, props) : child; + }); + }, + + clearTimers() { + clearTimeout(this.pressDurationTimerId); + }, + + componentWillUnmount() { + this.clearTimers(); + }, + + render() { + const { children, className, style } = this.props; + const { isHovering, isTouchOutside, cursorPosition } = this.state; + const childProps = assign( + {}, + { + isHovering, + isTouchOutside, + cursorPosition + }, + omit(this.props, [ + 'children', + 'className', + 'isActivatedOnTouch', + 'onActivationChanged', + 'onPositionChanged', + 'onTouchOutside', + 'pressDuration', + 'pressMoveThreshold', + 'shouldDecorateChildren', + 'style' + ]) + ); + + return ( +
+ { this.renderChildrenWithProps(children, childProps) } +
+ ); + } +}); diff --git a/demo/static/large-a.jpg b/demo/static/large-a.jpg new file mode 100644 index 00000000..33dda666 Binary files /dev/null and b/demo/static/large-a.jpg differ diff --git a/demo/static/large-b.jpg b/demo/static/large-b.jpg new file mode 100644 index 00000000..9218963c Binary files /dev/null and b/demo/static/large-b.jpg differ diff --git a/demo/static/small-a.jpg b/demo/static/small-a.jpg new file mode 100644 index 00000000..021e8325 Binary files /dev/null and b/demo/static/small-a.jpg differ diff --git a/demo/static/small-b.jpg b/demo/static/small-b.jpg new file mode 100644 index 00000000..3dd3748f Binary files /dev/null and b/demo/static/small-b.jpg differ diff --git a/demo/static/thumb-a.jpg b/demo/static/thumb-a.jpg new file mode 100644 index 00000000..f61e56a7 Binary files /dev/null and b/demo/static/thumb-a.jpg differ diff --git a/demo/static/thumb-b.jpg b/demo/static/thumb-b.jpg new file mode 100644 index 00000000..a322ef4d Binary files /dev/null and b/demo/static/thumb-b.jpg differ diff --git a/docs/now-touch-qr-100.jpg b/docs/now-touch-qr-100.jpg new file mode 100644 index 00000000..6d5a72d6 Binary files /dev/null and b/docs/now-touch-qr-100.jpg differ diff --git a/docs/now-touch-qr-150.jpg b/docs/now-touch-qr-150.jpg new file mode 100644 index 00000000..b947986b Binary files /dev/null and b/docs/now-touch-qr-150.jpg differ diff --git a/docs/now-touch-qr.jpg b/docs/now-touch-qr.jpg new file mode 100755 index 00000000..d8e72b95 Binary files /dev/null and b/docs/now-touch-qr.jpg differ diff --git a/docs/qr-2.png b/docs/qr-2.png new file mode 100644 index 00000000..4b991a5d Binary files /dev/null and b/docs/qr-2.png differ diff --git a/example/src/App.js b/example/src/App.js index 6a8a7a32..e1ebbdca 100644 --- a/example/src/App.js +++ b/example/src/App.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import logo from './logo.svg'; import './App.css'; import ReactImageMagnify from '../../dist/ReactImageMagnify'; +import ReactImageMagnifyTouch from '../../dist/ReactImageMagnifyTouch'; class App extends Component { render() { @@ -28,6 +29,26 @@ class App extends Component { height: 168 } }}/> + +
); } diff --git a/src/ReactImageMagnifyTouch.js b/src/ReactImageMagnifyTouch.js new file mode 100644 index 00000000..96d56821 --- /dev/null +++ b/src/ReactImageMagnifyTouch.js @@ -0,0 +1,89 @@ +import React, { PropTypes } from 'react'; +import ReactHoverObserver from 'react-hover-observer'; +import ReactTouchPosition from './ReactTouchPosition'; +import LensTop from './LensTop'; +import LensLeft from './LensLeft'; +import LensRight from './LensRight'; +import LensBottom from './LensBottom'; +import EnlargedImage from './EnlargedImage'; + +const ReactImageMagnify = ({ + className, + enlargedImageContainerStyle, + enlargedImageStyle, + fadeDurationInMs, + hoverDelayInMs, + hoverOffDelayInMs, + imageStyle, + largeImage, + lensStyle, + smallImage, + style +}) => { + + const cursorOffset = { + x: Math.round(((smallImage.width / largeImage.width) * smallImage.width) / 2), + y: Math.round(((smallImage.height / largeImage.height) * smallImage.height) / 2) + }; + + return ( + + + + + ); +} + +export const ImageShape = PropTypes.shape({ + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired +}); + +ReactImageMagnify.propTypes = { + className: PropTypes.string, + enlargedImageContainerStyle: PropTypes.object, + enlargedImageStyle: PropTypes.object, + fadeDurationInMs: PropTypes.number, + hoverDelayInMs: PropTypes.number, + hoverOffDelayInMs: PropTypes.number, + imageStyle: PropTypes.object, + largeImage: ImageShape, + lensStyle: PropTypes.object, + smallImage: ImageShape, + style: PropTypes.object +}; + +ReactImageMagnify.defaultProps = { + fadeDurationInMs: 300, + hoverDelayInMs: 250, + hoverOffDelayInMs: 150 +}; + +export default ReactImageMagnify; diff --git a/src/ReactTouchPosition.js b/src/ReactTouchPosition.js new file mode 100644 index 00000000..7e8b0958 --- /dev/null +++ b/src/ReactTouchPosition.js @@ -0,0 +1,230 @@ +import React, { + Children, + cloneElement, + PropTypes +} from 'react'; +import assign from 'lodash.assign'; +import noop from 'lodash.noop'; +import omit from 'lodash.omit'; + +export default React.createClass({ + + displayName: 'TouchPosition', + + getInitialState() { + return { + isHovering: false, + isTouchOutside: false, + cursorPosition: { + x: 0, + y: 0 + } + }; + }, + + propTypes: { + className: PropTypes.string, + isActivatedOnTouch: PropTypes.bool, + onActivationChanged: PropTypes.func, + onPositionChanged: PropTypes.func, + pressDuration: PropTypes.number, + pressMoveThreshold: PropTypes.number, + shouldDecorateChildren: PropTypes.bool, + style: PropTypes.object + }, + + getDefaultProps() { + return { + isActivatedOnTouch: false, + onActivationChanged: noop, + onPositionChanged: noop, + pressDuration: 500, + pressMoveThreshold: 5, + shouldDecorateChildren: true + }; + }, + + onTouchStart(e) { + this.elementOffsetRect = this.getViewportRelativeElementRect(e.currentTarget); + this.setPosition(e); + + if (this.props.isActivatedOnTouch) { + e.preventDefault(); + + this.setState({ + isHovering: true + }); + + return; + } + + this.initPressEventCriteria(e.touches[0]); + + this.setPressEventTimer() + }, + + onTouchMove(e) { + this.setPressEventCriteria(e.touches[0]); + + if (!this.state.isHovering) { + return; + } + + this.setPosition(e); + + e.preventDefault(); + }, + + onTouchEnd() { + this.clearTimers(); + + this.setState({ + isHovering: false, + isTouchOutside: false + }); + + this.props.onActivationChanged({ isHovering: false }); + }, + + setPosition(e) { + const viewportRelativeTouchPosition = this.getViewportRelativeTouchPosition(e); + const elementOffsetRect = this.elementOffsetRect; + const cursorPosition = this.getElementRelativeTouchPosition(viewportRelativeTouchPosition, elementOffsetRect); + const isPositionOutside = this.getIsPositionOutside(viewportRelativeTouchPosition, elementOffsetRect); + + this.setState({ + cursorPosition, + isPositionOutside + }); + + this.props.onPositionChanged(Object.assign({ isPositionOutside }, cursorPosition)); + }, + + setPressEventTimer() { + this.pressDurationTimerId = setTimeout(() => { + if (Math.abs(this.currentElTop - this.initialElTop) < this.props.pressMoveThreshold) { + this.setState({ isHovering: true }); + this.props.onActivationChanged({ isHovering: true }); + } + }, this.props.pressDuration); + }, + + setPressEventCriteria(touch) { + if (!this.props.isActivatedOnTouch) { + if (!this.state.isHovering) { + this.currentElTop = touch.clientY; + } else { + this.initialElTop = touch.clientY; + } + } + }, + + initPressEventCriteria(touch) { + const top = touch.clientY; + this.initialElTop = top; + this.currentElTop = top; + }, + + getViewportRelativeElementRect(el) { + return el.getBoundingClientRect(); + }, + + getIsPositionOutside(viewportRelativeTouchPosition, elementOffsetRect) { + const { x: viewportRelativeTouchX, y: viewportRelativeTouchY } = viewportRelativeTouchPosition; + const { + top: offsetTop, + right: offsetRight, + bottom: offsetBottom, + left: offsetLeft + } = elementOffsetRect; + return ( + viewportRelativeTouchX < offsetLeft || + viewportRelativeTouchX > offsetRight || + viewportRelativeTouchY < offsetTop || + viewportRelativeTouchY > offsetBottom + ); + }, + + getViewportRelativeTouchPosition(event) { + const touch = event.touches[0]; + return { + x: touch.clientX, + y: touch.clientY + } + }, + + getElementRelativeTouchPosition(viewportRelativetouchPosition, elementOffsetRect) { + const { x: touchX, y: touchY } = viewportRelativetouchPosition; + const { left: offsetX, top: offsetY } = elementOffsetRect; + + return { + x: touchX - offsetX, + y: touchY - offsetY + }; + }, + + isReactComponent(reactElement) { + return typeof reactElement.type === 'function'; + }, + + shouldDecorateChild(child) { + return this.isReactComponent(child) && this.props.shouldDecorateChildren; + }, + + decorateChild(child, props) { + return cloneElement(child, props); + }, + + renderChildrenWithProps(children, props) { + return Children.map(children, (child) => { + return this.shouldDecorateChild(child) ? this.decorateChild(child, props) : child; + }); + }, + + clearTimers() { + clearTimeout(this.pressDurationTimerId); + }, + + componentWillUnmount() { + this.clearTimers(); + }, + + render() { + const { children, className, style } = this.props; + const { isHovering, isTouchOutside, cursorPosition } = this.state; + const childProps = assign( + {}, + { + isHovering, + isTouchOutside, + cursorPosition + }, + omit(this.props, [ + 'children', + 'className', + 'isActivatedOnTouch', + 'onActivationChanged', + 'onPositionChanged', + 'onTouchOutside', + 'pressDuration', + 'pressMoveThreshold', + 'shouldDecorateChildren', + 'style' + ]) + ); + + return ( +
+ { this.renderChildrenWithProps(children, childProps) } +
+ ); + } +});