/*
* 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 <http://www.gnu.org/licenses/>.
*/
const { Config } = require( 'mmv.bootstrap' );
const {
HtmlUtils,
notifyTitleNotFound,
ImageInfo,
ImageModel,
License,
ThumbnailWidth,
ThumbnailWidthCalculator
} = require( 'mmv.common' );
const ViewLogger = require( './logging/mmv.logging.ViewLogger.js' );
const Canvas = require( './ui/mmv.ui.canvas.js' );
const CanvasButtons = require( './ui/mmv.ui.canvasButtons.js' );
const Description = require( './ui/mmv.ui.description.js' );
const Dialog = require( './ui/mmv.ui.dialog.js' );
const UiElement = require( './ui/mmv.ui.js' );
const MetadataPanel = require( './ui/mmv.ui.metadataPanel.js' );
const MetadataPanelScroller = require( './ui/mmv.ui.metadataPanelScroller.js' );
const Permission = require( './ui/mmv.ui.permission.js' );
const StripeButtons = require( './ui/mmv.ui.stripeButtons.js' );
const TruncatableTextField = require( './ui/mmv.ui.truncatableTextField.js' );
const LightboxInterface = require( './mmv.lightboxinterface.js' );
const config = require( './config.json' );
const router = require( 'mediawiki.router' );
/**
* Analyses the page, looks for image content and sets up the hooks
* to manage the viewing experience of such content.
*/
class MultimediaViewer {
constructor() {
const api = new mw.Api();
/**
* @type {ImageInfo}
* @private
*/
this.imageInfoProvider = new ImageInfo( api, {
language: Config.language()
} );
/**
* Image index on page.
*
* @type {number}
*/
this.currentIndex = 0;
/**
* @type {OO.Router}
*/
this.router = router;
this.comingFromHashChange = false;
/**
* UI object used to display the pictures in the page.
*
* @type {LightboxInterface}
* @private
*/
this.ui = new LightboxInterface();
/**
* base document title, MediaViewer will expand this
*
* @type {string}
*/
this.documentTitle = document.title;
/**
* @type {ViewLogger}
*/
this.viewLogger = new ViewLogger( window );
}
/**
* Initialize the lightbox interface given an array of thumbnail
* objects.
*
* @param {LightboxImage[]} thumbs
*/
initWithThumbs( thumbs ) {
this.thumbs = thumbs;
}
/**
* Handles resize events in viewer.
*/
resize() {
const image = this.thumbs[ this.currentIndex ];
if ( image ) {
const imageInfoPromise = this.fetchImageInfo( image );
imageInfoPromise.then( ( imageInfo ) => {
this.displayRealThumbnail( imageInfo );
} );
}
// This is done again later by displayRealThumbnail(), but do it now as well
// to ensure icon positioning responds quickly when resizing.
this.ui.updateControls( this.currentIndex, this.thumbs.length );
}
/**
* Loads a specified image.
*
* @param {LightboxImage} image
*/
loadImage( image ) {
const $initialImage = $( image.thumbnail ).clone();
const pluginsPromise = this.loadExtensionPlugins( image.filePageTitle.getExtension().toLowerCase() );
this.currentIndex = image.index;
this.currentImage = image;
if ( !this.isOpen ) {
$( document ).trigger( $.Event( 'mmv-setup-overlay' ) );
this.ui.open();
this.isOpen = true;
} else {
this.ui.empty();
}
this.setTitle();
// At this point we can't show the thumbnail because we don't
// know what size it should be. We still assign it to allow for
// size calculations in getCurrentImageWidths, which needs to know
// the aspect ratio
$initialImage
.removeAttr( 'style' )
.removeClass()
// Add file extension as class to display checkerboard pattern for transparent images
.addClass( `mw-mmv-placeholder-image ${ image.filePageTitle.getExtension().toLowerCase() }` );
const avoidBlowup = +$initialImage.attr( 'width' ) < 100 || +$initialImage.attr( 'height' ) < 100;
if ( avoidBlowup ) {
// avoid blowup of very small preview thumbnails
$initialImage.hide();
}
this.preloadImagesMetadata();
this.ui.canvas.set( image, $initialImage );
const imageInfoPromise = this.fetchImageInfo( image );
this.displayPlaceholderThumbnail( image, $initialImage );
if ( this.currentIndex !== image.index ) {
return;
}
$.when( imageInfoPromise, pluginsPromise ).then( ( imageInfo ) => {
$( document ).trigger( $.Event( 'mmv-metadata', { viewer: this, image, imageInfo } ) );
} );
const initialImageLoaded = new Promise( ( resolve ) => {
if ( $initialImage.prop( 'complete' ) || avoidBlowup ) {
resolve();
} else {
$initialImage.one( 'load', resolve );
}
} );
// Swapping the HTMLImageElement.src after the small image has loaded ensures that
// the larger image one is only being displayed once it has been loaded.
$.when( imageInfoPromise, initialImageLoaded ).then( ( imageInfo ) => {
if ( this.currentIndex !== image.index ) {
return;
}
this.displayRealThumbnail( imageInfo );
$initialImage.removeClass( 'mw-mmv-placeholder-image' );
if ( avoidBlowup ) {
$initialImage.show();
}
this.viewLogger.attach( imageInfo.url );
} );
imageInfoPromise.then(
// done
( imageInfo ) => {
if ( this.currentIndex !== image.index ) {
return;
}
this.ui.panel.setImageInfo( image, imageInfo );
// File reuse steals a bunch of information from the DOM, so do it last
this.ui.setFileReuseData( imageInfo, image.caption, image.alt );
},
// fail
( error ) => {
if ( this.currentIndex === image.index ) {
// Set title to caption or file name if caption is not available;
// see setTitle() in mmv.ui.metadataPanel for extended caption fallback
this.ui.panel.showError(
image.caption ?
HtmlUtils.htmlToTextWithTags( image.caption ) :
image.filePageTitle.getNameText(),
error
);
}
return $.Deferred().reject( error );
}
);
}
displayRealThumbnail( imageInfo ) {
this.ui.canvas.setImageAndMaxDimensions( imageInfo );
this.ui.updateControls( this.currentIndex, this.thumbs.length );
}
/**
* Loads an image by its title
*
* @param {mw.Title} title
* @param {number} [position]
*/
loadImageByTitle( title, position ) {
if ( !this.thumbs || !this.thumbs.length ) {
return;
}
const thumb = this.thumbs.find( ( t ) => t.filePageTitle.getPrefixedText() === title.getPrefixedText() &&
( !position || t.position === position )
);
if ( !thumb ) {
this.onTitleNotFound( title );
return;
}
this.loadImage( thumb );
}
/**
* When the image to load is not present on the current page,
* a notification is shown to the user and the MMV is closed.
*
* @param {mw.Title} title
* @private
*/
onTitleNotFound( title ) {
this.close();
notifyTitleNotFound( title );
}
/**
* Display the thumbnail from the page
*
* @param {LightboxImage} image
* @param {jQuery} $initialImage The thumbnail from the page
* @param {boolean} [recursion=false] for internal use, never set this when calling from outside
*/
displayPlaceholderThumbnail( image, $initialImage, recursion ) {
const size = { width: image.originalWidth, height: image.originalHeight };
// Width/height of the original image are added to the HTML by MediaViewer via a PHP hook,
// and can be missing in exotic circumstances, e. g. when the extension has only been
// enabled recently and the HTML cache has not cleared yet. If that is the case, we need
// to fetch the size from the API first.
if ( !size.width || !size.height ) {
if ( recursion ) {
// this should not be possible, but an infinite recursion is nasty
// business, so we make a sense check
throw new Error( 'MediaViewer internal error: displayPlaceholderThumbnail recursion' );
}
this.fetchImageInfo( image ).then( ( imageInfo ) => {
// Make sure the user has not navigated away while we were waiting for the size
if ( this.currentIndex === image.index ) {
image.originalWidth = imageInfo.width;
image.originalHeight = imageInfo.height;
this.displayPlaceholderThumbnail( image, $initialImage, true );
}
} );
} else {
this.ui.canvas.maybeDisplayPlaceholder( size, $initialImage );
}
}
/**
* Preload metadata for current and next image.
*/
preloadImagesMetadata() {
const current = this.currentIndex;
const next = this.currentIndex + 1;
[ current, next ].filter( ( i ) => i < this.thumbs.length ).forEach( ( i ) => {
const lightboxImage = this.thumbs[ i ];
this.fetchImageInfo( lightboxImage );
} );
}
/**
* Loads all the size-independent information needed by the lightbox (image metadata, repo
* information).
*
* @param {LightboxImage} image
* @return {Promise<ImageModel>}
*/
fetchImageInfo( image ) {
// Pass the handler-specific parameter (multilingual SVG `lang`, PDF `page`) so the
// API renders the thumbnail URLs as the same variant.
const urlParam = image.getUrlParam();
const p = this.imageInfoProvider.get( image.filePageTitle, urlParam ? urlParam.urlParam : undefined );
// Silence the "uncaught in promise" console warning for now
// TODO: Callers should handle this
p.catch( () => {} );
return p;
}
/**
* Loads an image at a specified index in the viewer's thumbnail array.
*
* @param {number} index
*/
loadIndex( index ) {
if ( index < this.thumbs.length && index >= 0 ) {
this.viewLogger.recordViewDuration();
const thumb = this.thumbs[ index ];
this.loadImage( thumb );
router.navigateTo( null, {
path: Config.getMediaHash( thumb.filePageTitle, thumb.position ),
useReplaceState: true
} );
}
}
/**
* Opens the last image
*/
firstImage() {
this.loadIndex( 0 );
}
/**
* Opens the last image
*/
lastImage() {
this.loadIndex( this.thumbs.length - 1 );
}
/**
* Opens the next image
*/
nextImage() {
if ( this.currentIndex === this.thumbs.length - 1 ) {
this.firstImage();
} else {
this.loadIndex( this.currentIndex + 1 );
}
}
/**
* Opens the previous image
*/
prevImage() {
if ( this.currentIndex === 0 ) {
this.lastImage();
} else {
this.loadIndex( this.currentIndex - 1 );
}
}
/**
* Handles close event coming from the lightbox
*/
close() {
this.viewLogger.recordViewDuration();
this.viewLogger.unattach();
if ( this.comingFromHashChange ) {
this.comingFromHashChange = false;
} else {
this.router.back();
}
// update title after route change, see T225387
document.title = this.createDocumentTitle( null );
// This has to happen after the hash reset, because setting the hash to # will reset the page scroll
$( document ).trigger( $.Event( 'mmv-cleanup-overlay' ) );
this.isOpen = false;
}
/**
* Updates the page title to reflect the current title.
*/
setTitle() {
// update title after route change, see T225387
document.title = this.createDocumentTitle( this.currentImage && this.currentImage.filePageTitle );
}
/**
* Creates a string which can be shown as document title (the text at the top of the browser window).
*
* @param {mw.Title|null} imageTitle the title object for the image which is displayed; null when the
* viewer is being closed
* @return {string}
*/
createDocumentTitle( imageTitle ) {
if ( imageTitle ) {
return `${ imageTitle.getNameText() } - ${ this.documentTitle }`;
} else {
return this.documentTitle;
}
}
/**
* Fired when the viewer is closed. This is used by the lightbox to notify the main app.
*
* @event MultimediaViewer#mmv-close
*/
/**
* Fired when the user requests the next image.
*
* @event MultimediaViewer#mmv-next
*/
/**
* Fired when the user requests the previous image.
*
* @event MultimediaViewer#mmv-prev
*/
/**
* Fired when the screen size changes. Debounced to avoid continuous triggering while resizing with a mouse.
*
* @event MultimediaViewer#mmv-resize-end
*/
/**
* Registers all event handlers
*/
setupEventHandlers() {
this.ui.connect( this, {
first: 'firstImage',
last: 'lastImage',
next: 'nextImage',
prev: 'prevImage'
} );
$( document ).on( 'mmv-close.mmvp', () => {
this.close();
} ).on( 'mmv-resize-end.mmvp', () => {
this.resize();
} ).on( 'mmv-viewfile.mmvp', () => {
this.fetchImageInfo( this.currentImage ).then( ( imageInfo ) => {
document.location = imageInfo.url;
} );
} );
}
/**
* Unregisters all event handlers. Currently only used in tests.
*/
cleanupEventHandlers() {
$( document ).off( 'mmv-close.mmvp mmv-resize-end.mmvp' );
this.ui.disconnect( this );
}
/**
* Loads the RL module defined for a given file extension, if any
*
* @param {string} extension File extension
* @return {jQuery.Promise}
*/
loadExtensionPlugins( extension ) {
if ( !( extension in config.extensions ) || config.extensions[ extension ] === 'default' ) {
return $.Deferred().resolve();
}
return mw.loader.using( config.extensions[ extension ] );
}
}
module.exports = {
Canvas,
CanvasButtons,
Description,
Dialog,
HtmlUtils,
ImageInfo,
ImageModel,
License,
LightboxInterface,
MetadataPanel,
MetadataPanelScroller,
MultimediaViewer,
Permission,
StripeButtons,
ThumbnailWidth,
ThumbnailWidthCalculator,
TruncatableTextField,
UiElement,
ViewLogger
};