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) => { return Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)) } const clamp = (min, max, value) => { return Math.min(max, Math.max(min, value)) } export default class ZoomableImage extends 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 (
{alt}
) } }