UNPKG

react-image-lightbox

Version:
559 lines (465 loc) 20.4 kB
/* * react-image-lightbox 1.0.0 * Copyright 2016 Chris Fritz All rights reserved. * @license Open source under the MIT License */ 'use strict'; var React = require('react'); var radium = require('radium'); var styles = require('./styles'); var ReactImageLightbox = React.createClass({ displayName: 'ReactImageLightbox', propTypes: { /////////////////////////////// // Image sources /////////////////////////////// // Main display image url mainSrc: React.PropTypes.string.isRequired, // Previous display image url (displayed to the left) // If left undefined, movePrev actions will not be performed, and the button not displayed prevSrc: React.PropTypes.string, // Next display image url (displayed to the right) // If left undefined, moveNext actions will not be performed, and the button not displayed nextSrc: React.PropTypes.string, /////////////////////////////// // Image thumbnail sources /////////////////////////////// // Thumbnail image url corresponding to props.mainSrc mainSrcThumbnail: React.PropTypes.string, // Thumbnail image url corresponding to props.prevSrc prevSrcThumbnail: React.PropTypes.string, // Thumbnail image url corresponding to props.nextSrc nextSrcThumbnail: React.PropTypes.string, /////////////////////////////// // Event Handlers /////////////////////////////// // Close window event // Should change the parent state such that the lightbox is not rendered onCloseRequest: React.PropTypes.func.isRequired, // Move to previous image event // Should change the parent state such that props.prevSrc becomes props.mainSrc, // props.mainSrc becomes props.nextSrc, etc. onMovePrevRequest: React.PropTypes.func, // Move to next image event // Should change the parent state such that props.nextSrc becomes props.mainSrc, // props.mainSrc becomes props.prevSrc, etc. onMoveNextRequest: React.PropTypes.func, /////////////////////////////// // Download discouragement settings /////////////////////////////// // Enable download discouragement (prevents [right-click -> Save Image As...]) discourageDownloads: React.PropTypes.bool, /////////////////////////////// // Animation settings /////////////////////////////// // Disable all animation animationDisabled: React.PropTypes.bool, // Disable animation on actions performed with keyboard shortcuts animationOnKeyInput: React.PropTypes.bool, // Animation duration (ms) animationDuration: React.PropTypes.number, /////////////////////////////// // Keyboard shortcut settings /////////////////////////////// // Required interval of time (ms) between key actions // (prevents excessively fast navigation of images) keyRepeatLimit: React.PropTypes.number, // Amount of time (ms) restored after each keyup // (makes rapid key presses slightly faster than holding down the key to navigate images) keyRepeatKeyupBonus: React.PropTypes.number, /////////////////////////////// // Image info /////////////////////////////// // Image title imageTitle: React.PropTypes.string, /////////////////////////////// // Other /////////////////////////////// // Array of custom toolbar buttons toolbarButtons: React.PropTypes.arrayOf(React.PropTypes.node), // Padding (px) between the edge of the window and the lightbox imagePadding: React.PropTypes.number }, getDefaultProps: function getDefaultProps() { return { onMovePrevRequest: function onMovePrevRequest() {}, onMoveNextRequest: function onMoveNextRequest() {}, discourageDownloads: false, animationDisabled: false, animationOnKeyInput: false, animationDuration: 300, keyRepeatLimit: 180, keyRepeatKeyupBonus: 40, imagePadding: 10 }; }, getInitialState: function getInitialState() { return { /////////////////////////////// // Animation /////////////////////////////// // Lightbox is closing // When Lightbox is mounted, if animation is enabled it will open with the reverse of the closing animation isClosing: !this.props.animationDisabled, // Main image is being replaced by the previous image isMovingToPrev: false, // Main image is being replaced by the next image isMovingToNext: false }; }, componentWillMount: function componentWillMount() { // Whether event listeners for keyboard and mouse input have been attached or not this.listenersAttached = false; // Used to disable animation when changing props.mainSrc|nextSrc|prevSrc this.keyPressed = false; // Used to store load state / dimensions of images this.imageCache = {}; // Time the last keydown event was called (used in keyboard action rate limiting) this.lastKeyDownTime = 0; }, componentDidMount: function componentDidMount() { this.attachListeners(); if (!this.props.animationDisabled) { // Make opening animation play this.setState({ isClosing: false }); } this.loadAllImages(); }, componentWillReceiveProps: function componentWillReceiveProps(nextProps) { var sourcesChanged = this.getSrcTypes().some((function (srcType) { return this.props[srcType] != nextProps[srcType]; }).bind(this)); if (sourcesChanged) { // Enable animated states if (!this.props.animationDisabled && (!this.keyPressed || this.props.animationOnKeyInput)) { var movedToPrev = this.props.mainSrc == nextProps.nextSrc; if (this.props.mainSrc == nextProps.nextSrc) { this.setState({ isMovingToPrev: true }); setTimeout((function () { this.setState({ isMovingToPrev: false }); }).bind(this), this.props.animationDuration); } else if (this.props.mainSrc == nextProps.prevSrc) { this.setState({ isMovingToNext: true }); setTimeout((function () { this.setState({ isMovingToNext: false }); }).bind(this), this.props.animationDuration); } } this.keyPressed = false; // Load any new images this.loadAllImages(nextProps); } }, componentWillUnmount: function componentWillUnmount() { this.detachListeners(); }, // Handle user keyboard actions handleKeyInput: function handleKeyInput(event) { event.stopPropagation(); // Ignore key input during animations if (this.isAnimating()) { return; } // Allow slightly faster navigation through the images when user presses keys repeatedly if (event.type === 'keyup') { this.lastKeyDownTime -= this.props.keyRepeatKeyupBonus; return; } var keyCode = event.which || event.keyCode; var key = { esc: 27, leftArrow: 37, rightArrow: 39 }; // Ignore key presses that happen too close to each other (when rapid fire key pressing or holding down the key) // But allow it if it's a lightbox closing action var currentTime = new Date(); if (currentTime.getTime() - this.lastKeyDownTime < this.props.keyRepeatLimit && keyCode != key.esc) { return; } this.lastKeyDownTime = currentTime.getTime(); switch (keyCode) { // ESC key closes the lightbox case key.esc: event.preventDefault(); if (this.props.animationDisabled || !this.props.animationOnKeyInput) { this.props.onCloseRequest(event); // immediate } else { this.requestClose(event); // animated } break; // Left arrow key moves to previous image case key.leftArrow: if (!this.props.prevSrc) { return; } event.preventDefault(); this.keyPressed = true; this.props.onMovePrevRequest(event); break; // Right arrow key moves to next image case key.rightArrow: if (!this.props.nextSrc) { return; } event.preventDefault(); this.keyPressed = true; this.props.onMoveNextRequest(event); break; default: } }, // Request that the lightbox be closed requestClose: function requestClose(event) { var closeLightbox = (function () { // Call the parent close request this.props.onCloseRequest(event); }).bind(this); if (!this.props.animationDisabled) { // Start closing animation this.setState({ isClosing: true }); // Perform the actual closing at the end of the animation setTimeout(closeLightbox, this.props.animationDuration); } else { closeLightbox(); } }, // Request to transition to the previous image requestMovePrev: function requestMovePrev(event) { this.props.onMovePrevRequest(event); }, // Request to transition to the next image requestMoveNext: function requestMoveNext(event) { this.props.onMoveNextRequest(event); }, // Attach key and mouse input events attachListeners: function attachListeners() { if (!this.listenersAttached) { document.addEventListener('keydown', this.handleKeyInput); document.addEventListener('keyup', this.handleKeyInput); this.listenersAttached = true; } }, // Detach key and mouse input events detachListeners: function detachListeners() { if (this.listenersAttached) { document.removeEventListener('keydown', this.handleKeyInput); document.removeEventListener('keyup', this.handleKeyInput); this.listenersAttached = false; } }, // Get image src types getSrcTypes: function getSrcTypes() { return ['mainSrc', 'mainSrcThumbnail', 'nextSrc', 'nextSrcThumbnail', 'prevSrc', 'prevSrcThumbnail']; }, // Get sizing for when an image is larger than the window getFitSizes: function getFitSizes(width, height) { var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; var windowWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth; var maxHeight = Math.min(windowHeight - this.props.imagePadding * 2, height); var maxWidth = Math.min(windowWidth - this.props.imagePadding * 2, width); var maxRatio = maxWidth / maxHeight; var srcRatio = width / height; var fitSizes = {}; if (maxRatio > srcRatio) { // height is the constraining dimension of the photo fitSizes.width = width * maxHeight / height; fitSizes.height = maxHeight; } else { fitSizes.width = maxWidth; fitSizes.height = height * maxWidth / width; } return fitSizes; }, // Detach key and mouse input events isAnimating: function isAnimating() { return this.state.isMovingToNext || this.state.isMovingToPrev || this.state.isClosing; }, // Load image from src and call callback with image width and height on load loadImage: function loadImage(imageSrc, callback) { // Return the image info if it is already cached if (this.isImageLoaded(imageSrc)) { setTimeout(function () { callback(null, this.imageCache[imageSrc].width, this.imageCache[imageSrc].height); }, 1); return; } var that = this; var inMemoryImage = new Image(); inMemoryImage.onerror = function () { callback('image load error'); }; inMemoryImage.onload = function () { that.imageCache[imageSrc] = { loaded: true, width: this.width, height: this.height }; callback(null, this.width, this.height); }; inMemoryImage.src = imageSrc; }, // Load all images and their thumbnails loadAllImages: function loadAllImages(props) { props = props || this.props; var generateImageLoadedCallback = (function (srcType, imageSrc) { return (function (err) { // Give up showing image on error if (err) { return; } // Don't rerender if the src is not the same as when the load started if (this.props[srcType] != imageSrc) { return; } // Force rerender with the new image this.forceUpdate(); }).bind(this); }).bind(this); // Load the images this.getSrcTypes().forEach((function (srcType) { // Load unloaded images if (props[srcType] && !this.isImageLoaded(props[srcType])) { this.loadImage(props[srcType], generateImageLoadedCallback(srcType, props[srcType])); } }).bind(this)); }, // Load image from src and call callback with image width and height on load isImageLoaded: function isImageLoaded(imageSrc) { return imageSrc && imageSrc in this.imageCache && this.imageCache[imageSrc].loaded; }, render: function render() { // Transition settings for sliding animations var transitionStyle = {}; if (!this.props.animationDisabled && this.isAnimating()) { transitionStyle = styles.imageAnimating(this.props.animationDuration); } // Images to be displayed var images = []; var addImage = (function (srcType, imageClass, baseStyle) { var imageSrc = this.props[srcType]; if (!imageSrc) { return; } var imageStyle = [styles.image, baseStyle, transitionStyle]; var fitSizes = {}; if (this.isImageLoaded(imageSrc)) { fitSizes = this.getFitSizes(this.imageCache[imageSrc].width, this.imageCache[imageSrc].height); } else if (this.isImageLoaded(this.props[srcType + 'Thumbnail'])) { // Fall back to using thumbnail if the image has not been loaded imageSrc = this.props[srcType + 'Thumbnail']; fitSizes = this.getFitSizes(this.imageCache[imageSrc].width, this.imageCache[imageSrc].height); } else { // Fall back to loading icon if the thumbnail has not been loaded images.push(React.createElement('div', { className: imageClass + ' not-loaded', style: imageStyle, key: imageSrc })); return; } imageStyle.push({ width: fitSizes.width, height: fitSizes.height }); if (this.props.discourageDownloads) { imageStyle.push({ backgroundImage: 'url(\'' + imageSrc + '\')' }); imageStyle.push(styles.imageDiscourager); images.push(React.createElement( 'div', { className: imageClass, style: imageStyle, key: imageSrc }, React.createElement('div', { className: 'download-blocker', style: [styles.image.downloadBlocker] }) )); } else { images.push(React.createElement('img', { className: imageClass, style: imageStyle, src: imageSrc, key: imageSrc })); } }).bind(this); // Next Image (displayed on the right) addImage('nextSrc', 'image-next', styles.imageNext); // Main Image addImage('mainSrc', 'image-current', styles.imageCurrent); // Previous Image (displayed on the left) addImage('prevSrc', 'image-prev', styles.imagePrev); var noop = function noop() {}; return React.createElement( 'div', { // Floating modal with closing animations className: "outer" + (this.state.isClosing ? ' closing' : ''), style: [styles.outer, styles.outer.animating(this.props.animationDuration), this.state.isClosing ? styles.outerClosing : {}] }, React.createElement( 'div', { // Image holder className: 'inner', style: [styles.inner] }, images ), !this.props.prevSrc ? '' : React.createElement('button', { // Move to previous image button type: 'button', className: 'prev-button', key: 'prev', style: [styles.navButtons, styles.navButtonPrev], onClick: !this.isAnimating() ? this.requestMovePrev : noop // Ignore clicks during animation }), !this.props.nextSrc ? '' : React.createElement('button', { // Move to next image button type: 'button', className: 'next-button', key: 'next', style: [styles.navButtons, styles.navButtonNext], onClick: !this.isAnimating() ? this.requestMoveNext : noop // Ignore clicks during animation }), React.createElement( 'div', { // Lightbox toolbar className: 'toolbar', style: [styles.toolbar] }, React.createElement( 'ul', { className: 'toolbar-left', style: [styles.toolbarSide, styles.toolbarLeftSide] }, React.createElement( 'li', { style: [styles.toolbarItem] }, React.createElement( 'span', { style: [styles.toolbarItemChild] }, this.props.imageTitle ) ) ), React.createElement( 'ul', { className: 'toolbar-right', style: [styles.toolbarSide, styles.toolbarRightSide] }, !this.props.toolbarButtons ? '' : this.props.toolbarButtons.map(function (button, i) { return React.createElement( 'li', { key: i, style: [styles.toolbarItem] }, button ); }), React.createElement( 'li', { style: [styles.toolbarItem] }, React.createElement('button', { // Lightbox close button type: 'button', className: 'close', style: [styles.toolbarItemChild, styles.closeButton], onClick: !this.isAnimating() ? this.requestClose : noop // Ignore clicks during animation }) ) ) ) ); } }); module.exports = radium(ReactImageLightbox);