UNPKG

react-trilogo-images

Version:

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

631 lines (568 loc) 15.6 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 DefaultSpinner from './components/Spinner'; import bindFunctions from './utils/bindFunctions'; import canUseDom from './utils/canUseDom'; import deepMerge from './utils/deepMerge'; import { ActionType } from './components/Icon'; // consumers sometimes provide incorrect type or casing function normalizeSourceSet (data) { const sourceSet = data.srcSet || data.srcset; if (Array.isArray(sourceSet)) { return sourceSet.join(); } return sourceSet; } class Lightbox extends Component { constructor (props) { super(props); this.theme = deepMerge(defaultTheme, props.theme); this.classes = StyleSheet.create(deepMerge(defaultStyles, this.theme)); this.state = { imageLoaded: false, left: null, top: 15, width: 0, height: 0, rotate: 0, imageWidth: 0, imageHeight: 0, scaleX: 1, scaleY: 1, }; this.containerWidth = 0; this.containerHeight = 0; this.footerHeight = 84; this.setContainerWidthHeight(); bindFunctions.call(this, [ 'gotoNext', 'gotoPrev', 'closeBackdrop', 'handleKeyboardInput', 'handleImageLoaded', 'handleAction', 'getImageCenterXY', 'handleZoom', 'handleRotate', ]); } getChildContext () { return { theme: this.theme, }; } componentDidMount () { if (this.props.isOpen) { if (this.props.enableKeyboardInput) { window.addEventListener('keydown', this.handleKeyboardInput); } if (typeof this.props.currentImage === 'number') { this.preloadImage(this.props.currentImage); } } } componentWillReceiveProps (nextProps) { if (!canUseDom) return; if (this.props.isOpen && nextProps.isOpen) { this.preloadImage(this.props.currentImage, nextProps); } // preload current image if (this.props.currentImage !== nextProps.currentImage || !this.props.isOpen && nextProps.isOpen) { const img = this.preloadImage(nextProps.currentImage, nextProps); if (img && img.complete) { this.setState({ imageLoaded: img.complete }); } } // 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 // ============================== setContainerWidthHeight () { this.containerWidth = window.innerWidth; this.containerHeight = window.innerHeight; } getImageCenterXY () { return { x: (this.state.left + this.state.width / 2), y: (this.state.top + this.state.height / 2), }; } getImgWidthHeight (imgWidth, imgHeight) { let width = 0; let height = 0; let maxWidth = this.containerWidth * 0.8; let maxHeight = (this.containerHeight - this.footerHeight) * 0.8; width = Math.min(maxWidth, imgWidth); height = (width / imgWidth) * imgHeight; if (height > maxHeight) { height = maxHeight; width = (height / imgHeight) * imgWidth; } return [width, height]; } handleAction (type) { switch (type) { case ActionType.prev: if (this.state.activeIndex - 1 >= 0) { this.handleChangeImg(this.state.activeIndex - 1); } break; case ActionType.next: if (this.state.activeIndex + 1 < this.props.images.length) { this.handleChangeImg(this.state.activeIndex + 1); } break; case ActionType.zoomIn: let imgCenterXY = this.getImageCenterXY(); this.handleZoom(imgCenterXY.x, imgCenterXY.y, 1, 0.1); this.props.onZoomIn && this.props.onZoomIn(); break; case ActionType.zoomOut: let imgCenterXY2 = this.getImageCenterXY(); this.handleZoom(imgCenterXY2.x, imgCenterXY2.y, -1, 0.1); this.props.onZoomOut && this.props.onZoomOut(); break; case ActionType.rotateLeft: this.handleRotate(); this.props.onRotateLeft && this.props.onRotateLeft(); break; case ActionType.rotateRight: this.handleRotate(true); this.props.onRotateRight && this.props.onRotateRight(); break; case ActionType.reset: this.loadImg(this.state.activeIndex); break; case ActionType.scaleX: this.handleScaleX(-1); break; case ActionType.scaleY: this.handleScaleY(-1); break; case ActionType.save: this.props.onSave && this.props.onSave(this.props.currentImage, { zoom: this.state.scaleX, rotation: this.state.rotate }); break; default: break; } } handleZoom (targetX, targetY, direct, scale) { let imgCenterXY = this.getImageCenterXY(); let diffX = targetX - imgCenterXY.x; let diffY = targetY - imgCenterXY.y; // when image width is 0, set original width let top = 0; let left = 0; let width = 0; let height = 0; let scaleX = 0; let scaleY = 0; if (this.state.width === 0) { const [imgWidth, imgHeight] = this.getImgWidthHeight(this.state.imageWidth, this.state.imageHeight); left = (this.containerWidth - imgWidth) / 2; top = (this.containerHeight - this.footerHeight - imgHeight) / 2; width = this.state.width + imgWidth; height = this.state.height + imgHeight; scaleX = scaleY = 1; } else { let directX = this.state.scaleX > 0 ? 1 : -1; let directY = this.state.scaleY > 0 ? 1 : -1; scaleX = this.state.scaleX + scale * direct * directX; scaleY = this.state.scaleY + scale * direct * directY; if (Math.abs(scaleX) < 0.1 || Math.abs(scaleY) < 0.1) { return; } top = this.state.top + -direct * diffY / this.state.scaleX * scale * directX; left = this.state.left + -direct * diffX / this.state.scaleY * scale * directY; width = this.state.width; height = this.state.height; } this.setState({ width: width, scaleX: Math.floor(scaleX * 10) / 10, scaleY: Math.floor(scaleY * 10) / 10, height: height, top: top, left: left, loading: false, }); } handleRotate (isRight = false) { this.setState({ rotate: (this.state.rotate + 90 * (isRight ? 1 : -1)) % 360, }); } preloadImage (idx, nextProps) { const data = nextProps ? nextProps.images[idx] : this.props.images[idx]; if (!data) return; const img = new Image(); const sourceSet = normalizeSourceSet(data); // TODO: add error handling for missing images img.onerror = () => { this.setState({ imageLoaded: true }); }; img.onload = () => { let imgWidth = img.width; let imgHeight = img.height; const [width, height] = this.getImgWidthHeight(imgWidth, imgHeight); let left = (this.containerWidth - width) / 2; let top = (this.containerHeight - height - this.footerHeight) / 2; this.setState({ width, height, left, top, imageWidth: imgWidth, imageHeight: imgHeight, rotate: data.initialRotation || 0, scaleX: data.initialZoom || 1, scaleY: data.initialZoom || 1, imageLoaded: true, }); }; img.src = data.src; if (sourceSet) img.srcset = sourceSet; return img; } gotoNext (event) { const { currentImage, images } = this.props; const { imageLoaded } = this.state; if (!imageLoaded || currentImage === (images.length - 1)) return; if (event) { event.preventDefault(); event.stopPropagation(); } this.props.onClickNext(); } gotoPrev (event) { const { currentImage } = this.props; const { imageLoaded } = this.state; if (!imageLoaded || 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; } handleImageLoaded () { this.setState({ imageLoaded: true }); } // ============================== // 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, isOpen, showThumbnails, width, } = this.props; const { imageLoaded } = this.state; 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> <div className={css(this.classes.content)} style={{ marginBottom: offsetThumbnails, maxWidth: width }}> {imageLoaded && this.renderHeader()} {this.renderImages()} {this.renderSpinner()} {imageLoaded && this.renderFooter()} </div> {imageLoaded && this.renderThumbnails()} {imageLoaded && this.renderArrowPrev()} {imageLoaded && this.renderArrowNext()} {this.props.preventScroll && <ScrollLock />} </div> </Container> ); } renderImages () { const { currentImage, images, onClickImage, } = this.props; const { imageLoaded } = this.state; if (!images || !images.length) return null; const image = images[currentImage]; const sourceSet = normalizeSourceSet(image); const sizes = sourceSet ? '100vw' : null; let imgStyle = { width: `${this.state.width}px`, height: 'auto', transform: `rotate(${this.state.rotate}deg) scaleX(${this.state.scaleX}) scaleY(${this.state.scaleY})`, }; 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, imageLoaded && this.classes.imageLoaded)} onClick={onClickImage} ref={(image) => { this.image = image; }} sizes={sizes} alt={image.alt} src={image.src} srcSet={sourceSet} style={imgStyle} /> </figure> ); } renderThumbnails () { const { images, currentImage, onClickThumbnail, showThumbnails, thumbnailOffset } = this.props; if (!showThumbnails) return; return ( <PaginatedThumbnails currentImage={currentImage} images={images} offset={thumbnailOffset} onClickThumbnail={onClickThumbnail} /> ); } renderHeader () { const { closeButtonTitle, customControls, onClose, showCloseButton, } = this.props; return ( <Header customControls={customControls} onClose={onClose} showCloseButton={showCloseButton} closeButtonTitle={closeButtonTitle} /> ); } renderFooter () { const { currentImage, images, imageCountSeparator, showImageCount, rotatable, zoomable, onSave, } = this.props; if (!images || !images.length) return null; return ( <Footer caption={images[currentImage].caption} countCurrent={currentImage + 1} countSeparator={imageCountSeparator} countTotal={images.length} onAction={this.handleAction} rotatable={rotatable} showCount={showImageCount} zoomable={zoomable} savable={!!onSave} /> ); } renderSpinner () { const { spinner, spinnerColor, spinnerSize, } = this.props; const { imageLoaded } = this.state; const Spinner = spinner; return ( <div className={css(this.classes.spinner, !imageLoaded && this.classes.spinnerActive)}> <Spinner color={spinnerColor} size={spinnerSize} /> </div> ); } 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, onRotateLeft: PropTypes.func, onRotateRight: PropTypes.func, onZoomIn: PropTypes.func, onZoomOut: PropTypes.func, preloadNextImage: PropTypes.bool, preventScroll: PropTypes.bool, rightArrowTitle: PropTypes.string, showCloseButton: PropTypes.bool, showImageCount: PropTypes.bool, showThumbnails: PropTypes.bool, spinner: PropTypes.func, spinnerColor: PropTypes.string, spinnerSize: PropTypes.number, 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, preventScroll: true, rightArrowTitle: 'Next (Right arrow key)', showCloseButton: true, showImageCount: true, spinner: DefaultSpinner, spinnerColor: 'white', spinnerSize: 100, theme: {}, thumbnailOffset: 2, width: 1024, }; Lightbox.childContextTypes = { theme: PropTypes.object.isRequired, }; const defaultStyles = { content: { position: 'relative', width: '100vw', height: '100vh', }, figure: { margin: 0, // remove browser default width: 'auto', height: 'auto', position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', }, image: { display: 'block', // removes browser default gutter margin: '0 auto', // maintain center on very short screens OR very narrow image maxWidth: '100%', // disable user select WebkitTouchCallout: 'none', userSelect: 'none', // opacity animation on image load opacity: 0, transition: 'opacity 0.3s', }, imageLoaded: { opacity: 1, }, spinner: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', // opacity animation to make spinner appear with delay opacity: 0, transition: 'opacity 0.3s', }, spinnerActive: { opacity: 1, }, }; export default Lightbox;