mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/MultimediaViewer
synced 2024-11-28 10:00:18 +00:00
aa2951f436
Bug: T77349 Change-Id: I8a494f20b4d9f4930fa89681857990416c0e840c
529 lines
14 KiB
JavaScript
529 lines
14 KiB
JavaScript
/*
|
|
* 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 Canvas = require( './ui/mmv.ui.canvas.js' );
|
|
const CanvasButtons = require( './ui/mmv.ui.canvasButtons.js' );
|
|
const MetadataPanel = require( './ui/mmv.ui.metadataPanel.js' );
|
|
const OptionsDialog = require( './ui/mmv.ui.viewingOptions.js' );
|
|
const ThumbnailWidthCalculator = require( './mmv.ThumbnailWidthCalculator.js' );
|
|
const UiElement = require( './ui/mmv.ui.js' );
|
|
|
|
/** Proxy for a Dialog. Initialises and attaches the dialog upon first use. */
|
|
class DialogProxy extends UiElement {
|
|
constructor( eventName, initDialog ) {
|
|
super();
|
|
this.eventName = eventName;
|
|
this.initDialog = initDialog;
|
|
}
|
|
|
|
attach() {
|
|
this.handleEvent( this.eventName, this.handleOpenCloseClick.bind( this ) );
|
|
}
|
|
|
|
set( ...setValues ) {
|
|
this.setValues = setValues;
|
|
}
|
|
|
|
handleOpenCloseClick() {
|
|
mw.loader.using( 'mmv.ui.reuse', ( req ) => {
|
|
this.unattach();
|
|
const dialog = this.initDialog( req );
|
|
dialog.attach();
|
|
dialog.set( ...this.setValues );
|
|
dialog.handleOpenCloseClick();
|
|
} );
|
|
}
|
|
|
|
closeDialog() {}
|
|
}
|
|
|
|
/**
|
|
* Represents the main interface of the lightbox
|
|
*/
|
|
class LightboxInterface extends UiElement {
|
|
|
|
constructor() {
|
|
const $wrapper = $( '<div>' )
|
|
.addClass( 'mw-mmv-wrapper' );
|
|
super( $wrapper );
|
|
this.$wrapper = $wrapper;
|
|
|
|
// When opening we might override the theme-color, so remember the original value
|
|
const metaElement = document.querySelector( 'meta[name="theme-color"]' );
|
|
this.originalThemeColor = metaElement ? metaElement.getAttribute( 'content' ) : null;
|
|
|
|
/** @property {Config} config - */
|
|
this.config = new Config();
|
|
|
|
/**
|
|
* @property {ThumbnailWidthCalculator}
|
|
* @private
|
|
*/
|
|
this.thumbnailWidthCalculator = new ThumbnailWidthCalculator();
|
|
|
|
this.$main = $( '<div>' )
|
|
.addClass( 'mw-mmv-main' );
|
|
|
|
// I blame CSS for this
|
|
this.$innerWrapper = $( '<div>' )
|
|
.addClass( 'mw-mmv-image-inner-wrapper' );
|
|
|
|
this.$imageWrapper = $( '<div>' )
|
|
.addClass( 'mw-mmv-image-wrapper' )
|
|
.append( this.$innerWrapper );
|
|
|
|
this.$preDiv = $( '<div>' )
|
|
// The overlay has no-invert, so the interface overlaid
|
|
// on it must also have no-invert
|
|
.addClass( 'mw-mmv-pre-image mw-no-invert' );
|
|
|
|
this.$postDiv = $( '<div>' )
|
|
.addClass( 'mw-mmv-post-image' );
|
|
|
|
this.$aboveFold = $( '<div>' )
|
|
.addClass( 'mw-mmv-above-fold' );
|
|
|
|
this.$main.append(
|
|
this.$preDiv,
|
|
this.$imageWrapper,
|
|
this.$postDiv
|
|
);
|
|
|
|
this.$wrapper.append(
|
|
this.$main
|
|
);
|
|
|
|
this.setupCanvasButtons();
|
|
|
|
this.panel = new MetadataPanel( this.$postDiv, this.$aboveFold, this.config );
|
|
this.buttons = new CanvasButtons( this.$preDiv, this.$closeButton, this.$fullscreenButton );
|
|
this.canvas = new Canvas( this.$innerWrapper, this.$imageWrapper, this.$wrapper );
|
|
|
|
/** @property {DialogProxy|ReuseDialog} */
|
|
this.fileReuse = new DialogProxy( 'mmv-reuse-open', ( req ) => {
|
|
const { ReuseDialog } = req( 'mmv.ui.reuse' );
|
|
this.fileReuse = new ReuseDialog( this.$preDiv, this.buttons.$download, this.config );
|
|
return this.fileReuse;
|
|
} );
|
|
/** @property {DialogProxy|DownloadDialog} */
|
|
this.downloadDialog = new DialogProxy( 'mmv-download-open', ( req ) => {
|
|
const { DownloadDialog } = req( 'mmv.ui.reuse' );
|
|
this.downloadDialog = new DownloadDialog( this.$preDiv, this.buttons.$download, this.config );
|
|
return this.downloadDialog;
|
|
} );
|
|
this.optionsDialog = new OptionsDialog( this.$preDiv, this.buttons.$options, this.config );
|
|
}
|
|
|
|
/**
|
|
* Sets up the file reuse data in the DOM
|
|
*
|
|
* @param {ImageModel} image
|
|
* @param {string} caption
|
|
* @param {string} alt
|
|
*/
|
|
setFileReuseData( image, caption, alt ) {
|
|
this.buttons.set( image );
|
|
this.fileReuse.set( image, caption, alt );
|
|
this.downloadDialog.set( image );
|
|
}
|
|
|
|
/**
|
|
* Empties the interface.
|
|
*/
|
|
empty() {
|
|
this.panel.empty();
|
|
this.canvas.empty();
|
|
this.buttons.empty();
|
|
|
|
this.$main.addClass( 'metadata-panel-is-closed' )
|
|
.removeClass( 'metadata-panel-is-open' );
|
|
}
|
|
|
|
/**
|
|
* Opens the lightbox.
|
|
*/
|
|
open() {
|
|
this.empty();
|
|
this.attach();
|
|
}
|
|
|
|
/**
|
|
* Attaches the interface to the DOM.
|
|
*
|
|
* @param {string} [parentId] parent id where we want to attach the UI. Defaults to document
|
|
* element, override is mainly used for testing.
|
|
*/
|
|
attach( parentId ) {
|
|
// Advanced description needs to be below the fold when the lightbox opens
|
|
// regardless of what the scroll value was prior to opening the lightbox
|
|
// If the lightbox is already attached, it means we're doing prev/next, and
|
|
// we should avoid scrolling the panel
|
|
if ( !this.attached ) {
|
|
$( window ).scrollTop( 0 );
|
|
}
|
|
|
|
// Make sure that the metadata is going to be at the bottom when it appears
|
|
// 83 is the height of the top metadata area. Which can't be measured by
|
|
// reading the DOM at this point of the execution, unfortunately
|
|
this.$postDiv.css( 'top', `${ $( window ).height() - 83 }px` );
|
|
|
|
// Re-appending the same content can have nasty side-effects
|
|
// Such as the browser leaving fullscreen mode if the fullscreened element is part of it
|
|
if ( this.currentlyAttached ) {
|
|
return;
|
|
}
|
|
|
|
// Make sure devices set their theming to dark to match the background of the viewer
|
|
this.setThemeColor( '#000000' );
|
|
|
|
this.handleEvent( 'keyup', ( e ) => {
|
|
if ( e.keyCode === 27 && !( e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) {
|
|
// Escape button pressed
|
|
this.unattach();
|
|
}
|
|
} );
|
|
|
|
this.handleEvent( 'fullscreenchange.lip', () => {
|
|
this.fullscreenChange();
|
|
} );
|
|
|
|
this.handleEvent( 'keydown', ( e ) => {
|
|
this.keydown( e );
|
|
} );
|
|
|
|
this.handleEvent( 'touchstart', ( e ) => {
|
|
this.touchTap( e );
|
|
} );
|
|
|
|
// mousemove generates a ton of events, which is why we throttle it
|
|
this.handleEvent( 'mousemove.lip', mw.util.throttle( ( e ) => {
|
|
this.mousemove( e );
|
|
}, 100, true ) );
|
|
|
|
this.buttons.connect( this, {
|
|
next: [ 'emit', 'next' ],
|
|
prev: [ 'emit', 'prev' ]
|
|
} );
|
|
|
|
const $parent = $( parentId || document.body );
|
|
|
|
// Clean up fullscreen data left attached to the DOM
|
|
this.$main.removeClass( 'jq-fullscreened' ).removeClass( 'user-inactive' );
|
|
this.isFullscreen = false;
|
|
|
|
$parent
|
|
.append(
|
|
this.$wrapper
|
|
);
|
|
this.currentlyAttached = true;
|
|
|
|
this.panel.attach();
|
|
|
|
this.canvas.attach();
|
|
|
|
// cross-communication between panel and canvas, sort of
|
|
this.$postDiv.on( 'mmv-metadata-open.lip', () => {
|
|
this.$main.addClass( 'metadata-panel-is-open' )
|
|
.removeClass( 'metadata-panel-is-closed' );
|
|
} ).on( 'mmv-metadata-close.lip', () => {
|
|
this.$main.removeClass( 'metadata-panel-is-open' )
|
|
.addClass( 'metadata-panel-is-closed' );
|
|
} );
|
|
this.$wrapper.on( 'mmv-panel-close-area-click.lip', () => {
|
|
this.panel.scroller.toggle( 'down' );
|
|
} );
|
|
|
|
this.buttons.attach();
|
|
|
|
this.fileReuse.attach();
|
|
this.downloadDialog.attach();
|
|
this.optionsDialog.attach();
|
|
|
|
this.attached = true;
|
|
}
|
|
|
|
/**
|
|
* Detaches the interface from the DOM.
|
|
*
|
|
* @fires MultimediaViewer#mmv-close
|
|
*/
|
|
unattach() {
|
|
// We trigger this event on the document because unattach() can run
|
|
// when the interface is unattached
|
|
// We're calling this before cleaning up (below) the DOM, as that
|
|
// appears to have an impact on automatic scroll restoration (which
|
|
// might happen as a result of this being closed) in FF
|
|
$( document ).trigger( $.Event( 'mmv-close' ) )
|
|
.off( 'fullscreenchange.lip' );
|
|
|
|
// Has to happen first so that the scroller can freeze with visible elements
|
|
this.panel.unattach();
|
|
|
|
this.$wrapper.detach();
|
|
|
|
this.currentlyAttached = false;
|
|
|
|
this.buttons.unattach();
|
|
|
|
this.$postDiv.off( '.lip' );
|
|
this.$wrapper.off( 'mmv-panel-close-area-click.lip' );
|
|
|
|
this.fileReuse.unattach();
|
|
this.fileReuse.closeDialog();
|
|
|
|
this.downloadDialog.unattach();
|
|
this.downloadDialog.closeDialog();
|
|
|
|
this.optionsDialog.unattach();
|
|
this.optionsDialog.closeDialog();
|
|
|
|
// Canvas listens for events from dialogs, so should be unattached at the end
|
|
this.canvas.unattach();
|
|
|
|
this.clearEvents();
|
|
|
|
this.buttons.disconnect( this, {
|
|
next: [ 'emit', 'next' ],
|
|
prev: [ 'emit', 'prev' ]
|
|
} );
|
|
|
|
this.setThemeColor( this.originalThemeColor );
|
|
|
|
this.attached = false;
|
|
}
|
|
|
|
/**
|
|
* Exits fullscreen mode.
|
|
*/
|
|
exitFullscreen() {
|
|
this.fullscreenButtonJustPressed = true;
|
|
if ( this.$main.get( 0 ) === document.fullscreenElement ) {
|
|
if ( document.exitFullscreen ) {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
this.isFullscreen = false;
|
|
this.$main.removeClass( 'jq-fullscreened' );
|
|
clearTimeout( this.interactionTimer );
|
|
this.userActivity();
|
|
}
|
|
|
|
/**
|
|
* Enters fullscreen mode.
|
|
*/
|
|
enterFullscreen() {
|
|
const el = this.$main.get( 0 );
|
|
if ( el.requestFullscreen ) {
|
|
el.requestFullscreen();
|
|
}
|
|
this.isFullscreen = true;
|
|
this.$main.addClass( 'jq-fullscreened' );
|
|
this.resetInteractionTimer();
|
|
this.userInactive();
|
|
}
|
|
|
|
/**
|
|
* Interrupt and reset the 3sec delay to hide the controls
|
|
*/
|
|
resetInteractionTimer() {
|
|
clearTimeout( this.interactionTimer );
|
|
this.interactionTimer = setTimeout( () => {
|
|
this.userInactive();
|
|
}, 3000 );
|
|
}
|
|
|
|
/**
|
|
* In fullscreen, hide the mouse cursor and the controls
|
|
* Called from resetInteractionTimer()
|
|
*/
|
|
userInactive() {
|
|
this.$main.addClass( 'user-inactive' );
|
|
}
|
|
|
|
/**
|
|
* In fullscreen, show the mouse cursor and the controls
|
|
* Call this after any interactivity
|
|
*/
|
|
userActivity() {
|
|
this.$main.removeClass( 'user-inactive' );
|
|
}
|
|
|
|
/**
|
|
* Setup for canvas navigation buttons
|
|
*/
|
|
setupCanvasButtons() {
|
|
this.$closeButton = $( '<button>' )
|
|
.addClass( 'cdx-button cdx-button--icon-only mw-mmv-button mw-mmv-close' )
|
|
.prop( 'title', mw.msg( 'multimediaviewer-close-popup-text' ) )
|
|
.append( $( '<span>' ).addClass( 'mw-mmv-icon' ) )
|
|
.on( 'click', () => {
|
|
this.unattach();
|
|
} );
|
|
|
|
this.$fullscreenButton = $( '<button>' )
|
|
.addClass( 'cdx-button cdx-button--icon-only mw-mmv-button mw-mmv-fullscreen' )
|
|
.prop( 'title', mw.msg( 'multimediaviewer-fullscreen-popup-text' ) )
|
|
.append( $( '<span>' ).addClass( 'mw-mmv-icon' ) )
|
|
.on( 'click', () => {
|
|
if ( this.isFullscreen ) {
|
|
this.exitFullscreen();
|
|
} else {
|
|
this.enterFullscreen();
|
|
}
|
|
} );
|
|
}
|
|
|
|
/**
|
|
* Handle a fullscreen change event.
|
|
*/
|
|
fullscreenChange() {
|
|
// eslint-disable-next-line compat/compat
|
|
this.isFullscreen = !!document.fullscreenElement;
|
|
if ( this.isFullscreen ) {
|
|
this.$fullscreenButton
|
|
.prop( 'title', mw.msg( 'multimediaviewer-defullscreen-popup-text' ) )
|
|
.attr( 'alt', mw.msg( 'multimediaviewer-defullscreen-popup-text' ) );
|
|
} else {
|
|
this.$fullscreenButton
|
|
.prop( 'title', mw.msg( 'multimediaviewer-fullscreen-popup-text' ) )
|
|
.attr( 'alt', mw.msg( 'multimediaviewer-fullscreen-popup-text' ) );
|
|
}
|
|
|
|
if ( !this.fullscreenButtonJustPressed && !this.isFullscreen ) {
|
|
// Close the interface all the way if the user pressed 'esc'
|
|
this.unattach();
|
|
} else if ( this.fullscreenButtonJustPressed ) {
|
|
this.fullscreenButtonJustPressed = false;
|
|
}
|
|
|
|
// Fullscreen change events can happen after unattach(), in which
|
|
// case we shouldn't do anything UI-related
|
|
if ( !this.currentlyAttached ) {
|
|
return;
|
|
}
|
|
|
|
if ( this.isFullscreen ) {
|
|
this.userInactive();
|
|
}
|
|
|
|
// Some browsers only send resize events before toggling fullscreen, but not once the toggling is done
|
|
// This makes sure that the UI is properly resized after a fullscreen change
|
|
this.$main.trigger( $.Event( 'mmv-resize-end' ) );
|
|
}
|
|
|
|
/**
|
|
* Handles keydown events on the document
|
|
*
|
|
* @param {jQuery.Event} e The jQuery keypress event object
|
|
*/
|
|
keydown( e ) {
|
|
const isRtl = $( document.body ).hasClass( 'rtl' );
|
|
|
|
if ( e.altKey || e.shiftKey || e.ctrlKey || e.metaKey ) {
|
|
return;
|
|
}
|
|
if ( e.key === 'ArrowLeft' ) {
|
|
this.emit( isRtl ? 'next' : 'prev' );
|
|
e.preventDefault();
|
|
} else if ( e.key === 'ArrowRight' ) {
|
|
this.emit( isRtl ? 'prev' : 'next' );
|
|
e.preventDefault();
|
|
} else if ( e.key === 'Home' ) {
|
|
this.emit( 'first' );
|
|
e.preventDefault();
|
|
} else if ( e.key === 'End' ) {
|
|
this.emit( 'last' );
|
|
e.preventDefault();
|
|
} else if ( this.isFullscreen ) {
|
|
// Any other key in fullscreen reveals the controls
|
|
this.resetInteractionTimer();
|
|
this.userActivity();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles mousemove events on the document
|
|
*
|
|
* @param {jQuery.Event} e The mousemove event object
|
|
*/
|
|
mousemove( e ) {
|
|
// T77869 ignore fake mousemove events triggered by Chrome
|
|
if (
|
|
e &&
|
|
e.originalEvent &&
|
|
e.originalEvent.movementX === 0 &&
|
|
e.originalEvent.movementY === 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if ( this.isFullscreen ) {
|
|
this.resetInteractionTimer();
|
|
this.userActivity();
|
|
}
|
|
}
|
|
|
|
touchTap() {
|
|
if ( this.isFullscreen ) {
|
|
this.resetInteractionTimer();
|
|
this.userActivity();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the next and prev buttons
|
|
*
|
|
* @param {boolean} showPrevButton Whether the prev button should be revealed or not
|
|
* @param {boolean} showNextButton Whether the next button should be revealed or not
|
|
*/
|
|
updateControls( showPrevButton, showNextButton ) {
|
|
const prevNextTop = `${ ( this.$imageWrapper.height() - 60 ) / 2 }px`;
|
|
|
|
if ( this.isFullscreen ) {
|
|
this.$postDiv.css( 'top', '' );
|
|
} else {
|
|
this.$postDiv.css( 'top', this.$imageWrapper.height() );
|
|
}
|
|
|
|
this.buttons.setOffset( prevNextTop );
|
|
this.buttons.toggle( showPrevButton, showNextButton );
|
|
}
|
|
|
|
/**
|
|
* Update the theme-color of the document
|
|
*
|
|
* @param {string|null} color to set as theme-color or null to remove the theme-color
|
|
*/
|
|
setThemeColor( color ) {
|
|
let metaElement = document.querySelector( 'meta[name="theme-color"]' );
|
|
if ( !metaElement ) {
|
|
metaElement = document.createElement( 'meta' );
|
|
metaElement.setAttribute( 'name', 'theme-color' );
|
|
document.head.appendChild( metaElement );
|
|
}
|
|
if ( color === null ) {
|
|
metaElement.remove();
|
|
} else {
|
|
this.originalThemeColor = metaElement.getAttribute( 'content' );
|
|
metaElement.setAttribute( 'content', color );
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = LightboxInterface;
|