import React from 'react'; import PropTypes from 'prop-types'; const MIN_SCALE = 1; const MAX_SCALE = 4; const getMidpoint = (p1, p2) => ({ x: (p1.clientX + p2.clientX) / 2, y: (p1.clientY + p2.clientY) / 2, }); const getDistance = (p1, p2) => Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); export default class ZoomableImage extends React.PureComponent { static propTypes = { alt: PropTypes.string, src: PropTypes.string.isRequired, width: PropTypes.number, height: PropTypes.number, onClick: PropTypes.func, } static defaultProps = { alt: '', width: null, height: null, }; state = { scale: MIN_SCALE, } removers = []; container = null; image = null; lastTouchEndTime = 0; lastDistance = 0; componentDidMount () { let handler = this.handleTouchStart; this.container.addEventListener('touchstart', handler); this.removers.push(() => this.container.removeEventListener('touchstart', handler)); handler = this.handleTouchMove; // on Chrome 56+, touch event listeners will default to passive // https://www.chromestatus.com/features/5093566007214080 this.container.addEventListener('touchmove', handler, { passive: false }); this.removers.push(() => this.container.removeEventListener('touchend', handler)); } componentWillUnmount () { this.removeEventListeners(); } removeEventListeners () { this.removers.forEach(listeners => listeners()); this.removers = []; } handleTouchStart = e => { if (e.touches.length !== 2) return; this.lastDistance = getDistance(...e.touches); } handleTouchMove = e => { const { scrollTop, scrollHeight, clientHeight } = this.container; if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { // prevent propagating event to MediaModal e.stopPropagation(); return; } if (e.touches.length !== 2) return; e.preventDefault(); e.stopPropagation(); const distance = getDistance(...e.touches); const midpoint = getMidpoint(...e.touches); const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); this.zoom(scale, midpoint); this.lastMidpoint = midpoint; this.lastDistance = distance; } zoom(nextScale, midpoint) { const { scale } = this.state; const { scrollLeft, scrollTop } = this.container; // math memo: // x = (scrollLeft + midpoint.x) / scrollWidth // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth // scrollWidth = clientWidth * scale // scrollWidth' = clientWidth * nextScale // Solve x = x' for nextScrollLeft const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; this.setState({ scale: nextScale }, () => { this.container.scrollLeft = nextScrollLeft; this.container.scrollTop = nextScrollTop; }); } handleClick = e => { // don't propagate event to MediaModal e.stopPropagation(); const handler = this.props.onClick; if (handler) handler(); } setContainerRef = c => { this.container = c; } setImageRef = c => { this.image = c; } render () { const { alt, src } = this.props; const { scale } = this.state; const overflow = scale === 1 ? 'hidden' : 'scroll'; return ( <div className='zoomable-image' ref={this.setContainerRef} style={{ overflow }} > <img role='presentation' ref={this.setImageRef} alt={alt} title={alt} src={src} style={{ transform: `scale(${scale})`, transformOrigin: '0 0', }} onClick={this.handleClick} /> </div> ); } }