UNPKG

mk-react-images

Version:

A simple, responsive lightbox component for displaying an array of images with React.js

356 lines (319 loc) 8.85 kB
import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { css, StyleSheet } from 'aphrodite'; import ScrollLock from 'react-scrolllock'; import defaultTheme from './theme'; import Arrow from './components/Arrow'; import Container from './components/Container'; import Footer from './components/Footer'; import Header from './components/Header'; import PaginatedThumbnails from './components/PaginatedThumbnails'; import Portal from './components/Portal'; import bindFunctions from './utils/bindFunctions'; import canUseDom from './utils/canUseDom'; import deepMerge from './utils/deepMerge'; class Lightbox extends Component { constructor (props) { super(props); this.theme = deepMerge(defaultTheme, props.theme); this.classes = StyleSheet.create(deepMerge(defaultStyles, this.theme)); bindFunctions.call(this, [ 'gotoNext', 'gotoPrev', 'closeBackdrop', 'handleKeyboardInput', ]); } getChildContext () { return { theme: this.theme, }; } componentDidMount () { if (this.props.isOpen && this.props.enableKeyboardInput) { window.addEventListener('keydown', this.handleKeyboardInput); } } componentWillReceiveProps (nextProps) { if (!canUseDom) return; // preload images if (nextProps.preloadNextImage) { const currentIndex = this.props.currentImage; const nextIndex = nextProps.currentImage + 1; const prevIndex = nextProps.currentImage - 1; let preloadIndex; if (currentIndex && nextProps.currentImage > currentIndex) { preloadIndex = nextIndex; } else if (currentIndex && nextProps.currentImage < currentIndex) { preloadIndex = prevIndex; } // if we know the user's direction just get one image // otherwise, to be safe, we need to grab one in each direction if (preloadIndex) { this.preloadImage(preloadIndex); } else { this.preloadImage(prevIndex); this.preloadImage(nextIndex); } } // add/remove event listeners if (!this.props.isOpen && nextProps.isOpen && nextProps.enableKeyboardInput) { window.addEventListener('keydown', this.handleKeyboardInput); } if (!nextProps.isOpen && nextProps.enableKeyboardInput) { window.removeEventListener('keydown', this.handleKeyboardInput); } } componentWillUnmount () { if (this.props.enableKeyboardInput) { window.removeEventListener('keydown', this.handleKeyboardInput); } } // ============================== // METHODS // ============================== preloadImage (idx) { const image = this.props.images[idx]; if (!image) return; const img = new Image(); img.src = image.src; img.srcset = img.srcSet || img.srcset; if (image.srcset) { img.srcset = image.srcset.join(); } } gotoNext (event) { if (this.props.currentImage === (this.props.images.length - 1)) return; if (event) { event.preventDefault(); event.stopPropagation(); } this.props.onClickNext(); } gotoPrev (event) { if (this.props.currentImage === 0) return; if (event) { event.preventDefault(); event.stopPropagation(); } this.props.onClickPrev(); } closeBackdrop (event) { // make sure event only happens if they click the backdrop // and if the caption is widening the figure element let that respond too if (event.target.id === 'lightboxBackdrop' || event.target.tagName === 'FIGURE') { this.props.onClose(); } } handleKeyboardInput (event) { if (event.keyCode === 37) { // left this.gotoPrev(event); return true; } else if (event.keyCode === 39) { // right this.gotoNext(event); return true; } else if (event.keyCode === 27) { // esc this.props.onClose(); return true; } return false; } // ============================== // RENDERERS // ============================== renderArrowPrev () { if (this.props.currentImage === 0) return null; return ( <Arrow direction="left" icon="arrowLeft" onClick={this.gotoPrev} title={this.props.leftArrowTitle} type="button" /> ); } renderArrowNext () { if (this.props.currentImage === (this.props.images.length - 1)) return null; return ( <Arrow direction="right" icon="arrowRight" onClick={this.gotoNext} title={this.props.rightArrowTitle} type="button" /> ); } renderDialog () { const { backdropClosesModal, customControls, isOpen, onClose, showCloseButton, showThumbnails, width, } = this.props; if (!isOpen) return <span key="closed" />; let offsetThumbnails = 0; if (showThumbnails) { offsetThumbnails = this.theme.thumbnail.size + this.theme.container.gutter.vertical; } return ( <Container key="open" onClick={backdropClosesModal && this.closeBackdrop} onTouchEnd={backdropClosesModal && this.closeBackdrop} > <div className={css(this.classes.content)} style={{ marginBottom: offsetThumbnails, maxWidth: width }}> <Header customControls={customControls} onClose={onClose} showCloseButton={showCloseButton} closeButtonTitle={this.props.closeButtonTitle} /> {this.renderImages()} </div> {this.renderThumbnails()} {this.renderArrowPrev()} {this.renderArrowNext()} </Container> ); } renderImages () { const { currentImage, images, imageCountSeparator, onClickImage, showImageCount, showThumbnails, } = this.props; if (!images || !images.length) return null; const image = images[currentImage]; image.srcset = image.srcSet || image.srcset; let srcset; let sizes; if (image.srcset) { srcset = image.srcset.join(); sizes = '100vw'; } const thumbnailsSize = showThumbnails ? this.theme.thumbnail.size : 0; const heightOffset = `${this.theme.header.height + this.theme.footer.height + thumbnailsSize + (this.theme.container.gutter.vertical)}px`; return ( <figure className={css(this.classes.figure)}> {/* Re-implement when react warning "unknown props" https://fb.me/react-unknown-prop is resolved <Swipeable onSwipedLeft={this.gotoNext} onSwipedRight={this.gotoPrev} /> */} <img className={css(this.classes.image)} onClick={!!onClickImage && onClickImage} sizes={sizes} alt={image.alt} src={image.src} srcSet={srcset} style={{ cursor: this.props.onClickImage ? 'pointer' : 'auto', maxHeight: `calc(100vh - ${heightOffset})`, }} /> <Footer caption={images[currentImage].caption} countCurrent={currentImage + 1} countSeparator={imageCountSeparator} countTotal={images.length} showCount={showImageCount} /> </figure> ); } renderThumbnails () { const { images, currentImage, onClickThumbnail, showThumbnails, thumbnailOffset } = this.props; if (!showThumbnails) return; return ( <PaginatedThumbnails currentImage={currentImage} images={images} offset={thumbnailOffset} onClickThumbnail={onClickThumbnail} /> ); } render () { return ( <Portal> {this.renderDialog()} </Portal> ); } } Lightbox.propTypes = { backdropClosesModal: PropTypes.bool, closeButtonTitle: PropTypes.string, currentImage: PropTypes.number, customControls: PropTypes.arrayOf(PropTypes.node), enableKeyboardInput: PropTypes.bool, imageCountSeparator: PropTypes.string, images: PropTypes.arrayOf( PropTypes.shape({ src: PropTypes.string.isRequired, srcset: PropTypes.array, caption: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), thumbnail: PropTypes.string, }) ).isRequired, isOpen: PropTypes.bool, leftArrowTitle: PropTypes.string, onClickImage: PropTypes.func, onClickNext: PropTypes.func, onClickPrev: PropTypes.func, onClose: PropTypes.func.isRequired, preloadNextImage: PropTypes.bool, rightArrowTitle: PropTypes.string, showCloseButton: PropTypes.bool, showImageCount: PropTypes.bool, showThumbnails: PropTypes.bool, theme: PropTypes.object, thumbnailOffset: PropTypes.number, width: PropTypes.number, }; Lightbox.defaultProps = { closeButtonTitle: 'Close (Esc)', currentImage: 0, enableKeyboardInput: true, imageCountSeparator: ' of ', leftArrowTitle: 'Previous (Left arrow key)', onClickShowNextImage: true, preloadNextImage: true, rightArrowTitle: 'Next (Right arrow key)', showCloseButton: true, showImageCount: true, theme: {}, thumbnailOffset: 2, width: 1024, }; Lightbox.childContextTypes = { theme: PropTypes.object.isRequired, }; const defaultStyles = { content: { position: 'relative', }, figure: { margin: 0, // remove browser default }, image: { display: 'block', // removes browser default gutter height: 'auto', margin: '0 auto', // maintain center on very short screens OR very narrow image maxWidth: '100%', // disable user select WebkitTouchCallout: 'none', userSelect: 'none', }, }; export default Lightbox;