const View = require( '../mobile.startup/View' ), util = require( '../mobile.startup/util' ), mfExtend = require( '../mobile.startup/mfExtend' ), IconButton = require( '../mobile.startup/IconButton' ), icons = require( '../mobile.startup/icons' ), eventBus = require( '../mobile.startup/eventBusSingleton' ), Button = require( '../mobile.startup/Button' ), detailsButton = new Button( { label: mw.msg( 'mobile-frontend-media-details' ), additionalClassNames: 'button', progressive: true } ), slideLeftButton = new IconButton( { rotation: 90, icon: 'expand-invert', label: mw.msg( 'mobile-frontend-media-prev' ) } ), slideRightButton = new IconButton( { rotation: -90, icon: 'expand-invert', label: mw.msg( 'mobile-frontend-media-next' ) } ), LoadErrorMessage = require( './LoadErrorMessage' ), ImageGateway = require( './ImageGateway' ), router = __non_webpack_require__( 'mediawiki.router' ); /** * Displays images in full screen overlay * * @class ImageCarousel * @extends module:mobile.startup/View * @param {Object} options Configuration options, see Overlay#defaults * @private */ function ImageCarousel( options ) { this.gateway = options.gateway || new ImageGateway( { api: options.api } ); this.router = options.router || router; this.eventBus = options.eventBus; this.hasLoadError = false; View.call( this, util.extend( { className: 'image-carousel', events: { 'click .image-wrapper': 'onToggleDetails', // Click tracking for table of contents so we can see if people interact with it 'click .slider-button': 'onSlide' } }, options ) ); } mfExtend( ImageCarousel, View, { /** * @memberof ImageCarousel * @instance */ template: util.template( `

{{caption}}

{{licenseLinkMsg}}

` ), /** * @memberof ImageCarousel * @instance * @mixes Overlay#defaults * @property {Object} defaults Default options hash. * @property {mw.Api} defaults.api instance of API to use * @property {string} defaults.licenseLinkMsg Link to license information in media viewer. * @property {string} defaults.prevMsg Title for "prev" button in media viewer. * @property {string} defaults.nextMsg Title for "next" button in media viewer. * @property {Thumbnail[]} defaults.thumbnails a list of thumbnails to browse */ defaults: util.extend( {}, View.prototype.defaults, { licenseLinkMsg: mw.msg( 'mobile-frontend-media-license-link' ), prevMsg: mw.msg( 'mobile-frontend-media-prev' ), nextMsg: mw.msg( 'mobile-frontend-media-next' ), thumbnails: [] } ), /** * Event handler for slide event * * @memberof ImageCarousel * @instance * @param {jQuery.Event} ev */ onSlide: function ( ev ) { const nextThumbnail = this.$el.find( ev.target ).closest( '.slider-button' ).data( 'thumbnail' ), title = nextThumbnail.options.filename; this.router.navigateTo( null, { path: '#/media/' + title, useReplaceState: true } ); this.options.title = nextThumbnail.options.filename; const newImageCarousel = new ImageCarousel( this.options ); this.$el.replaceWith( newImageCarousel.$el ); this.$el = newImageCarousel.$el; }, /** * @inheritdoc * @memberof ImageCarousel * @instance */ preRender: function () { const self = this; this.options.thumbnails.forEach( function ( thumbnail, i ) { if ( thumbnail.getFileName() === self.options.title ) { self.options.caption = thumbnail.getDescription(); self.galleryOffset = i; } } ); }, /** * Setup the next and previous images to enable the user to arrow through * all images in the set of images given in thumbs. * * @memberof ImageCarousel * @instance * @param {Array} thumbs A set of images, which are available * @private */ _enableArrowImages: function ( thumbs ) { const offset = this.galleryOffset; let lastThumb, nextThumb; if ( this.galleryOffset === undefined ) { // couldn't find a suitable matching thumbnail so make // next slide start at beginning and previous slide be end lastThumb = thumbs[thumbs.length - 1]; nextThumb = thumbs[0]; } else { // identify last thumbnail lastThumb = thumbs[ offset === 0 ? thumbs.length - 1 : offset - 1 ]; nextThumb = thumbs[ offset === thumbs.length - 1 ? 0 : offset + 1 ]; } this.$el.find( '.prev' ).data( 'thumbnail', lastThumb ); this.$el.find( '.next' ).data( 'thumbnail', nextThumb ); }, /** * Disables the possibility to arrow through all images of the page. * * @memberof ImageCarousel * @instance * @private */ _disableArrowImages: function () { this.$el.find( '.prev, .next' ).remove(); }, /** * Handler for retry event which triggers when user tries to reload overlay * after a loading error. * * @memberof ImageCarousel * @instance * @private */ _handleRetry: function () { // A hacky way to simulate a reload of the overlay this.router.emit( 'hashchange' ); }, /** * @inheritdoc * @memberof ImageCarousel * @instance */ postRender: function () { let $img; const $el = this.$el, $spinner = icons.spinner().$el, thumbs = this.options.thumbnails || [], self = this; /** * Display media load failure message * * @method * @ignore */ function showLoadFailMsg() { self.hasLoadError = true; $spinner.hide(); // hide broken image if present $el.find( '.image img' ).hide(); // show error message if not visible already if ( $el.find( '.load-fail-msg' ).length === 0 ) { new LoadErrorMessage( { retryPath: self.router.getPath() } ) .on( 'retry', self._handleRetry.bind( self ) ) .prependTo( $el.find( '.image' ) ); } } /** * Start image load transitions * * @method * @ignore */ function addImageLoadClass() { $img.addClass( 'image-loaded' ); } if ( thumbs.length < 2 ) { this._disableArrowImages(); } else { this._enableArrowImages( thumbs ); } this.$details = $el.find( '.image-details' ); $el.find( '.image' ).append( $spinner ); this.$details.prepend( detailsButton.$el ); this.gateway.getThumb( self.options.title ).then( function ( data ) { let author; const url = data.descriptionurl + '#mw-jump-to-license'; $spinner.hide(); self.thumbWidth = data.thumbwidth; self.thumbHeight = data.thumbheight; self.imgRatio = data.thumbwidth / data.thumbheight; // We need to explicitly specify document for context param as jQuery 3 // will create a new document for the element if the context is // undefined. If element is appended to active document, event handlers // can fire in both the active document and new document which can cause // insidious bugs. // (https://api.jquery.com/jquery.parsehtml/#entry-longdesc) $img = self.parseHTML( '', document ); // Remove the loader when the image is loaded or display load fail // message on failure // // Error event handler must be attached before error occurs // (https://api.jquery.com/error/#entry-longdesc) // // For the load event, it is more unclear what happens cross-browser when // the image is loaded from cache. It seems that a .complete check is // needed if attaching the load event after setting the src. // (http://stackoverflow.com/questions/910727/jquery-event-for-images-loaded#comment10616132_1110094) // // However, perhaps .complete check is not needed if attaching load // event prior to setting the image src // (https://stackoverflow.com/questions/12354865/image-onload-event-and-browser-cache#answer-12355031) $img.on( 'load', addImageLoadClass ).on( 'error', showLoadFailMsg ); $img.attr( 'src', data.thumburl ).attr( 'alt', self.options.caption ); $el.find( '.image' ).append( $img ); self.$details.addClass( 'is-visible' ); self._positionImage(); $el.find( '.image-details a' ).attr( 'href', url ); if ( data.extmetadata ) { // Add license information if ( data.extmetadata.LicenseShortName ) { $el.find( '.license a' ) .text( data.extmetadata.LicenseShortName.value ) .attr( 'href', url ); } // Add author information if ( data.extmetadata.Artist ) { // Strip any tags author = data.extmetadata.Artist.value.replace( /<.*?>/g, '' ); $el.find( '.license' ).prepend( author + ' • ' ); } } self.adjustDetails(); }, function () { // retrieving image location failed so show load fail msg showLoadFailMsg(); } ); eventBus.on( 'resize:throttled', this._positionImage.bind( this ) ); this._positionImage(); }, /** * Event handler that toggles the details bar. * * @memberof ImageCarousel * @instance */ onToggleDetails: function () { if ( !this.hasLoadError ) { this.$el.find( '.cancel, .slider-button' ).toggle(); this.$details.toggle(); this._positionImage(); } }, /** * Fit the image into the window if its dimensions are bigger than the window dimensions. * Compare window width to height ratio to that of image width to height when setting * image width or height. * * @memberof ImageCarousel * @instance * @private */ _positionImage: function () { const $window = util.getWindow(); this.adjustDetails(); // with a hidden details box we have a little bit more space, we just need to use it // TODO: Get visibility from the model // eslint-disable-next-line no-jquery/no-sizzle const detailsHeight = !this.$details.is( ':visible' ) ? 0 : this.$details.outerHeight(); const windowWidth = $window.width(); const windowHeight = $window.height() - detailsHeight; const windowRatio = windowWidth / windowHeight; const $img = this.$el.find( 'img' ); if ( this.imgRatio > windowRatio ) { if ( windowWidth < this.thumbWidth ) { $img.css( { width: windowWidth, height: 'auto' } ); } } else { if ( windowHeight < this.thumbHeight ) { $img.css( { width: 'auto', height: windowHeight } ); } } this.$el.find( '.image-wrapper' ).css( 'bottom', detailsHeight ); this.$el.find( '.slider-button.prev' ).append( slideLeftButton.$el ); this.$el.find( '.slider-button.next' ).append( slideRightButton.$el ); }, /** * Function to adjust the height of details section to not more than 50% of window height. * * @memberof ImageCarousel * @instance */ adjustDetails: function () { const windowHeight = util.getWindow().height(); if ( this.$el.find( '.image-details' ).height() > windowHeight * 0.50 ) { this.$el.find( '.image-details' ).css( 'max-height', windowHeight * 0.50 ); } } } ); module.exports = ImageCarousel;