/* * This file is part of the MediaWiki extension MultimediaViewer. * * MultimediaViewer is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * MultimediaViewer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with MultimediaViewer. If not, see . */ const { getMediaHash, ROUTE_REGEXP, POSITION_REGEXP, LEGACY_ROUTE_REGEXP, isMediaViewerEnabledOnClick } = require( 'mmv.head' ); const Config = require( './mmv.Config.js' ); const HtmlUtils = require( './mmv.HtmlUtils.js' ); const LightboxImage = require( './mmv.lightboximage.js' ); const mwRouter = require( 'mediawiki.router' ); // We pass this to history.pushState/replaceState to indicate that we're controlling the page URL. // Then we look for this marker on page load so that if the page is refreshed, we don't generate an // extra history entry. const MANAGED_STATE = 'MMV was here!'; /** * Bootstrap code listening to thumb clicks checking the initial location.hash * Loads the mmv and opens it if necessary */ class MultimediaViewerBootstrap { constructor() { // Exposed for tests this.hoverWaitDuration = 200; // TODO lazy-load config and htmlUtils /** @property {Config} config - */ this.config = new Config( mw.config.get( 'wgMultimediaViewer', {} ), mw.config, mw.user, new mw.Api(), mw.storage ); this.validExtensions = this.config.extensions(); /** * This flag is set to true when we were unable to load the viewer. * * @property {boolean} */ this.viewerIsBroken = false; this.thumbsReadyDeferred = $.Deferred(); /** * @property {LightboxImage[]} */ this.thumbs = []; this.$thumbs = null; // will be set by processThumbs this.$parsoidThumbs = null; // will be set in processThumbs // find and setup all thumbs on this page // this will run initially and then every time the content changes, // e.g. via a VE edit or pagination in a multipage file mw.hook( 'wikipage.content' ).add( this.processThumbs.bind( this ) ); // Setup the router this.setupRouter( mwRouter ); } /** * Routes to a given file. * * @param {string} fileName */ route( fileName ) { this.loadViewer( true ).then( ( viewer ) => { let fileTitle; viewer.comingFromHashChange = true; try { let position = fileName.match( POSITION_REGEXP ); if ( position ) { position = +position[ 1 ]; fileName = fileName.replace( POSITION_REGEXP, '' ); } else { position = undefined; } fileName = decodeURIComponent( fileName ); fileTitle = new mw.Title( fileName ); viewer.loadImageByTitle( fileTitle, position ); } catch ( err ) { // ignore routes to invalid titles mw.log.warn( err ); } } ); } /** * Sets up the route handlers * * @param {OO.Router} router */ setupRouter( router ) { router.addRoute( ROUTE_REGEXP, this.route.bind( this ) ); router.addRoute( LEGACY_ROUTE_REGEXP, this.route.bind( this ) ); this.router = router; } /** * Loads the mmv module asynchronously and passes the thumb data to it * * @param {boolean} [setupOverlay] * @return {jQuery.Promise} */ loadViewer( setupOverlay ) { const deferred = $.Deferred(); let viewer; let message; // Don't load if someone has specifically stopped us from doing so if ( mw.config.get( 'wgMediaViewer' ) !== true ) { return deferred.reject(); } if ( history.scrollRestoration ) { history.scrollRestoration = 'manual'; } // FIXME setupOverlay is a quick hack to avoid setting up and immediately // removing the overlay on a not-MMV -> not-MMV hash change. // loadViewer is called on every click and hash change and setting up // the overlay is not needed on all of those; this logic really should // not be here. if ( setupOverlay ) { this.setupOverlay(); } mw.loader.using( 'mmv', ( req ) => { try { viewer = this.getViewer( req ); } catch ( e ) { message = e.message; if ( e.stack ) { message += `\n${ e.stack }`; } deferred.reject( message ); return; } deferred.resolve( viewer ); }, ( error ) => { deferred.reject( error.message ); } ); return deferred.promise() .then( ( viewer2 ) => { if ( !this.viewerInitialized ) { if ( this.thumbs.length ) { viewer2.initWithThumbs( this.thumbs ); } this.viewerInitialized = true; } return viewer2; }, ( message2 ) => { mw.log.warn( message2 ); this.cleanupOverlay(); this.viewerIsBroken = true; mw.notify( `Error loading MediaViewer: ${ message2 }` ); return $.Deferred().reject( message2 ); } ).always( () => { if ( this.$loadBar ) { this.$loadBar.remove(); } } ); } /** * Processes all thumbs found on the page * * @param {jQuery} $content Element to search for thumbs */ processThumbs( $content ) { // MMVB.processThumbs() is a callback for `wikipage.content` hook (see constructor) // which as state in the documentation can be fired when content is added to the DOM // https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw.hook // The content being added can contain thumbnails that the MultimediaViewer may need to // process correctly and add the thumbs array, so it's necessary to invalidate the // viewer initialization state if this happens to let the MMVB.loadViewer() to process // new images correctly this.viewerInitialized = false; this.$parsoidThumbs = $content.find( '[typeof*="mw:File"] a.mw-file-description img, ' + // TODO: Remove mw:Image when version 2.4.0 of the content is no // longer supported '[typeof*="mw:Image"] a.mw-file-description img' ); this.$thumbs = $content .find( '.gallery .image img, ' + 'a.image img, ' + 'a.mw-file-description img, ' + '#file a img' ) // Skip duplicates that are actually Parsoid thumbs .not( this.$parsoidThumbs ); try { this.$thumbs.each( ( i, thumb ) => this.processThumb( thumb ) ); this.$parsoidThumbs.each( ( i, thumb ) => this.processParsoidThumb( thumb ) ); } finally { this.thumbsReadyDeferred.resolve(); // now that we have set up our real click handler we can remove the temporary // handler added in mmv.head.js which just replays clicks to the real handler $( document ).off( 'click.mmv-head' ); } } /** * Check if this thumbnail should be handled by MediaViewer * * @param {jQuery} $thumb the thumbnail (an `` element) in question * @return {boolean} */ isAllowedThumb( $thumb ) { const selectors = [ '.metadata', // this is inside an informational template like {{refimprove}} on enwiki. '.noviewer', // MediaViewer has been specifically disabled for this image '.noarticletext', // we are on an error page for a non-existing article, the image is part of some template '#siteNotice', 'ul.mw-gallery-slideshow li.gallerybox' // thumbnails of a slideshow gallery ]; return $thumb.closest( selectors.join( ', ' ) ).length === 0; } /** * @param {mw.Title|null} title * @return {boolean} */ isValidExtension( title ) { return title && title.getExtension() && ( title.getExtension().toLowerCase() in this.validExtensions ); } /** * Preload JS/CSS when the mouse cursor hovers the thumb container * (thumb image + caption + border) * * @param {jQuery} $thumbContainer */ preloadAssets( $thumbContainer ) { $thumbContainer.on( { mouseenter: () => { // There is no point preloading if clicking the thumb won't open Media Viewer if ( !isMediaViewerEnabledOnClick() ) { return; } this.preloadOnHoverTimer = setTimeout( () => { mw.loader.load( 'mmv' ); }, this.hoverWaitDuration ); }, mouseleave: () => { if ( this.preloadOnHoverTimer ) { clearTimeout( this.preloadOnHoverTimer ); } } } ); } /** * Processes a thumb * * @param {Object} thumb */ processThumb( thumb ) { let title; const $thumb = $( thumb ); const $link = $thumb.closest( 'a.image, a.mw-file-description' ); const $thumbContainer = $link.closest( '.thumb' ); const $enlarge = $thumbContainer.find( '.magnify a' ); const isFilePageMainThumb = $thumb.closest( '#file' ).length > 0; if ( isFilePageMainThumb ) { // main thumbnail (file preview area) of a file page // if this is a PDF filetype thumbnail, it can trick us, // so we short-circuit that logic and use the file page title // instead of the thumbnail logic. title = mw.Title.newFromText( mw.config.get( 'wgTitle' ), mw.config.get( 'wgNamespaceNumber' ) ); } else { title = mw.Title.newFromImg( $thumb ); } if ( !this.isValidExtension( title ) ) { // Short-circuit event handler and interface setup, because // we can't do anything for this filetype return; } if ( !this.isAllowedThumb( $thumb ) ) { return; } if ( $thumbContainer.length ) { this.preloadAssets( $thumbContainer ); } if ( isFilePageMainThumb ) { this.processFilePageThumb( $thumb, title ); return; } // This is the data that will be passed onto the mmv const image = new LightboxImage( $thumb.prop( 'src' ), title, this.thumbs.length, this.thumbs.filter( ( t ) => t.filePageTitle.getPrefixedText() === title.getPrefixedText() ).length + 1, $thumb[ 0 ], this.findCaption( $thumbContainer, $link ) ); this.thumbs.push( image ); $link.add( $enlarge ).on( 'click', ( e ) => this.click( e, image ) ); } /** * Processes a Parsoid thumb, making use of the specified structure, * https://www.mediawiki.org/wiki/Specs/HTML#Media * * @param {Object} thumb */ processParsoidThumb( thumb ) { const $thumb = $( thumb ); const $link = $thumb.closest( 'a.mw-file-description' ); const $thumbContainer = $link.closest( '[typeof*="mw:File"], ' + // TODO: Remove mw:Image when version 2.4.0 of the content is // no longer supported '[typeof*="mw:Image"]' ); const title = mw.Title.newFromImg( $thumb ); let caption; let $thumbCaption; if ( !this.isValidExtension( title ) ) { // Short-circuit event handler and interface setup, because // we can't do anything for this filetype return; } if ( !this.isAllowedThumb( $thumb ) ) { return; } if ( $thumbContainer.length ) { this.preloadAssets( $thumbContainer ); } if ( ( $thumbContainer.prop( 'tagName' ) || '' ).toLowerCase() === 'figure' ) { $thumbCaption = $thumbContainer.find( 'figcaption' ); caption = HtmlUtils.htmlToTextWithTags( $thumbCaption.html() || '' ); } else { caption = $link.prop( 'title' ) || undefined; } // This is the data that will be passed onto the mmv const image = new LightboxImage( $thumb.prop( 'src' ), title, this.thumbs.length, this.thumbs.filter( ( t ) => t.filePageTitle.getPrefixedText() === title.getPrefixedText() ).length + 1, $thumb[ 0 ], caption ); this.thumbs.push( image ); $link.on( 'click', ( e ) => this.click( e, image ) ); } /** * Processes the main thumbnail of a file page by adding some buttons * below to open MediaViewer. * * @param {jQuery} $thumb * @param {mw.Title} title */ processFilePageThumb( $thumb, title ) { // remove the buttons (and the clearing element) if they are already there // this should not happen (at least until we support paged media) but just in case // eslint-disable-next-line no-jquery/no-global-selector $( '.mw-mmv-filepage-buttons' ).next().addBack().remove(); const $mmvButton = $( '