/*!
* VisualEditor user interface MWMediaDialog class.
*
* @copyright 2011-2017 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Dialog for inserting and editing MediaWiki media.
*
* @class
* @extends ve.ui.NodeDialog
*
* @constructor
* @param {Object} [config] Configuration options
*/
ve.ui.MWMediaDialog = function VeUiMWMediaDialog( config ) {
// Parent constructor
ve.ui.MWMediaDialog.super.call( this, config );
// Properties
this.imageModel = null;
this.pageTitle = '';
this.isSettingUpModel = false;
this.isInsertion = false;
this.selectedImageInfo = null;
this.searchCache = {};
this.$element.addClass( 've-ui-mwMediaDialog' );
};
/* Inheritance */
OO.inheritClass( ve.ui.MWMediaDialog, ve.ui.NodeDialog );
/* Static Properties */
ve.ui.MWMediaDialog.static.name = 'media';
ve.ui.MWMediaDialog.static.title =
OO.ui.deferMsg( 'visualeditor-dialog-media-title' );
ve.ui.MWMediaDialog.static.size = 'large';
ve.ui.MWMediaDialog.static.actions = [
{
action: 'apply',
label: OO.ui.deferMsg( 'visualeditor-dialog-action-apply' ),
flags: [ 'progressive', 'primary' ],
modes: 'edit'
},
{
action: 'insert',
label: OO.ui.deferMsg( 'visualeditor-dialog-action-insert' ),
flags: [ 'primary', 'constructive' ],
modes: 'insert'
},
{
action: 'change',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-change-image' ),
modes: [ 'edit', 'insert' ]
},
{
action: 'choose',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-choose-image' ),
flags: [ 'primary', 'progressive' ],
modes: [ 'info' ]
},
{
action: 'upload',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-upload' ),
flags: [ 'primary', 'progressive' ],
modes: [ 'upload-upload' ]
},
{
action: 'save',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-save' ),
flags: [ 'primary', 'progressive' ],
modes: [ 'upload-info' ]
},
{
action: 'cancelchoose',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-goback' ),
flags: [ 'safe', 'back' ],
modes: [ 'info' ]
},
{
action: 'cancelupload',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-goback' ),
flags: [ 'safe', 'back' ],
modes: [ 'upload-info' ]
},
{
label: OO.ui.deferMsg( 'visualeditor-dialog-action-cancel' ),
flags: [ 'safe', 'back' ],
modes: [ 'edit', 'insert', 'select', 'search', 'upload-upload' ]
},
{
action: 'back',
label: OO.ui.deferMsg( 'visualeditor-dialog-media-goback' ),
flags: [ 'safe', 'back' ],
modes: [ 'change' ]
}
];
ve.ui.MWMediaDialog.static.modelClasses = [ ve.dm.MWBlockImageNode, ve.dm.MWInlineImageNode ];
ve.ui.MWMediaDialog.static.includeCommands = null;
ve.ui.MWMediaDialog.static.excludeCommands = [
// No formatting
'paragraph',
'heading1',
'heading2',
'heading3',
'heading4',
'heading5',
'heading6',
'preformatted',
'blockquote',
// TODO: Decide if tables tools should be allowed
'tableCellHeader',
'tableCellData',
// No structure
'bullet',
'bulletWrapOnce',
'number',
'numberWrapOnce',
'indent',
'outdent'
];
/**
* Get the import rules for the surface widget in the dialog
*
* @see ve.dm.ElementLinearData#sanitize
* @return {Object} Import rules
*/
ve.ui.MWMediaDialog.static.getImportRules = function () {
return ve.extendObject(
ve.copy( ve.init.target.constructor.static.importRules ),
{
all: {
blacklist: OO.simpleArrayUnion(
ve.getProp( ve.init.target.constructor.static.importRules, 'all', 'blacklist' ) || [],
[
// Tables (but not lists) are possible in wikitext with a leading
// line break but we prevent creating these with the UI
'list', 'listItem', 'definitionList', 'definitionListItem',
'table', 'tableCaption', 'tableSection', 'tableRow', 'tableCell'
]
),
// Headings are also possible, but discouraged
conversions: {
mwHeading: 'paragraph'
}
}
}
);
};
/* Methods */
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getBodyHeight = function () {
// FIXME: This should vary on panel.
return 600;
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.initialize = function () {
var altTextFieldset, positionFieldset, borderField, positionField;
// Parent method
ve.ui.MWMediaDialog.super.prototype.initialize.call( this );
this.panels = new OO.ui.StackLayout();
// Set up the booklet layout
this.mediaSettingsBooklet = new OO.ui.BookletLayout( {
classes: [ 've-ui-mwMediaDialog-panel-settings' ],
outlined: true
} );
this.mediaSearchPanel = new OO.ui.PanelLayout( {
classes: [ 've-ui-mwMediaDialog-panel-search' ],
scrollable: true
} );
this.mediaUploadBooklet = new mw.ForeignStructuredUpload.BookletLayout( { $overlay: this.$overlay } );
this.mediaImageInfoPanel = new OO.ui.PanelLayout( {
classes: [ 've-ui-mwMediaDialog-panel-imageinfo' ],
scrollable: false
} );
this.$infoPanelWrapper = $( '
' ).addClass( 've-ui-mwMediaDialog-panel-imageinfo-wrapper' );
this.generalSettingsPage = new OO.ui.PageLayout( 'general' );
this.advancedSettingsPage = new OO.ui.PageLayout( 'advanced' );
this.mediaSettingsBooklet.addPages( [
this.generalSettingsPage, this.advancedSettingsPage
] );
this.generalSettingsPage.getOutlineItem()
.setIcon( 'parameter' )
.setLabel( ve.msg( 'visualeditor-dialog-media-page-general' ) );
this.advancedSettingsPage.getOutlineItem()
.setIcon( 'parameter' )
.setLabel( ve.msg( 'visualeditor-dialog-media-page-advanced' ) );
// Define the media search page
this.searchTabs = new OO.ui.IndexLayout();
this.searchTabs.addCards( [
new OO.ui.CardLayout( 'search', {
label: ve.msg( 'visualeditor-dialog-media-search-tab-search' )
} ),
new OO.ui.CardLayout( 'upload', {
label: ve.msg( 'visualeditor-dialog-media-search-tab-upload' ),
content: [ this.mediaUploadBooklet ]
} )
] );
this.search = new mw.widgets.MediaSearchWidget();
// Define fieldsets for image settings
// Filename
this.filenameFieldset = new OO.ui.FieldsetLayout( {
label: ve.msg( 'visualeditor-dialog-media-content-filename' ),
icon: 'image'
} );
// Caption
// Set up the caption target
this.captionTarget = ve.init.target.createTargetWidget( {
tools: ve.init.target.constructor.static.toolbarGroups,
includeCommands: this.constructor.static.includeCommands,
excludeCommands: this.constructor.static.excludeCommands,
importRules: this.constructor.static.getImportRules()
} );
this.captionFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-content-section' ),
help: ve.msg( 'visualeditor-dialog-media-content-section-help' ),
icon: 'parameter',
classes: [ 've-ui-mwMediaDialog-caption-fieldset' ]
} );
this.captionFieldset.$element.append( this.captionTarget.$element );
// Alt text
altTextFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-alttext-section' ),
help: ve.msg( 'visualeditor-dialog-media-alttext-section-help' ),
icon: 'parameter'
} );
this.altTextInput = new OO.ui.TextInputWidget();
this.altTextInput.$element.addClass( 've-ui-mwMediaDialog-altText' );
// Build alt text fieldset
altTextFieldset.$element
.append( this.altTextInput.$element );
// Position
this.positionSelect = new ve.ui.AlignWidget( {
dir: this.getDir()
} );
this.positionCheckbox = new OO.ui.CheckboxInputWidget();
positionField = new OO.ui.FieldLayout( this.positionCheckbox, {
$overlay: this.$overlay,
align: 'inline',
label: ve.msg( 'visualeditor-dialog-media-position-checkbox' ),
help: ve.msg( 'visualeditor-dialog-media-position-checkbox-help' )
} );
positionFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-position-section' ),
help: ve.msg( 'visualeditor-dialog-media-position-section-help' ),
icon: 'parameter'
} );
// Build position fieldset
positionFieldset.$element.append(
positionField.$element,
this.positionSelect.$element
);
// Type
this.typeFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-type-section' ),
help: ve.msg( 'visualeditor-dialog-media-type-section-help' ),
icon: 'parameter'
} );
this.typeSelect = new OO.ui.ButtonSelectWidget();
this.typeSelect.addItems( [
// TODO: Inline images require a bit of further work, will be coming soon
new OO.ui.ButtonOptionWidget( {
data: 'thumb',
icon: 'image-thumbnail',
label: ve.msg( 'visualeditor-dialog-media-type-thumb' )
} ),
new OO.ui.ButtonOptionWidget( {
data: 'frameless',
icon: 'image-frameless',
label: ve.msg( 'visualeditor-dialog-media-type-frameless' )
} ),
new OO.ui.ButtonOptionWidget( {
data: 'frame',
icon: 'image-frame',
label: ve.msg( 'visualeditor-dialog-media-type-frame' )
} ),
new OO.ui.ButtonOptionWidget( {
data: 'none',
icon: 'image-none',
label: ve.msg( 'visualeditor-dialog-media-type-none' )
} )
] );
this.borderCheckbox = new OO.ui.CheckboxInputWidget();
borderField = new OO.ui.FieldLayout( this.borderCheckbox, {
align: 'inline',
label: ve.msg( 'visualeditor-dialog-media-type-border' )
} );
borderField.$element.addClass( 've-ui-mwMediaDialog-borderCheckbox' );
// Build type fieldset
this.typeFieldset.$element.append(
this.typeSelect.$element,
borderField.$element
);
// Size
this.sizeFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-size-section' ),
icon: 'parameter',
help: ve.msg( 'visualeditor-dialog-media-size-section-help' )
} );
this.sizeErrorLabel = new OO.ui.LabelWidget( {
label: ve.msg( 'visualeditor-dialog-media-size-originalsize-error' )
} );
this.sizeWidget = new ve.ui.MediaSizeWidget();
this.$sizeWidgetElements = $( '
' ).append(
this.sizeWidget.$element,
this.sizeErrorLabel.$element
);
this.sizeFieldset.$element.append(
this.$sizeWidgetElements
);
// Events
this.positionCheckbox.connect( this, { change: 'onPositionCheckboxChange' } );
this.borderCheckbox.connect( this, { change: 'onBorderCheckboxChange' } );
this.positionSelect.connect( this, { choose: 'onPositionSelectChoose' } );
this.typeSelect.connect( this, { choose: 'onTypeSelectChoose' } );
this.search.getResults().connect( this, { choose: 'onSearchResultsChoose' } );
this.captionTarget.connect( this, { change: 'checkChanged' } );
this.altTextInput.connect( this, { change: 'onAlternateTextChange' } );
this.searchTabs.connect( this, {
set: 'onSearchTabsSet'
} );
this.mediaUploadBooklet.connect( this, {
set: 'onMediaUploadBookletSet',
uploadValid: 'onUploadValid',
infoValid: 'onInfoValid'
} );
// Initialization
this.searchTabs.getCard( 'search' ).$element.append( this.search.$element );
this.mediaSearchPanel.$element.append( this.searchTabs.$element );
this.generalSettingsPage.$element.append(
this.filenameFieldset.$element,
this.captionFieldset.$element,
altTextFieldset.$element
);
this.advancedSettingsPage.$element.append(
positionFieldset.$element,
this.typeFieldset.$element,
this.sizeFieldset.$element
);
this.panels.addItems( [
this.mediaSearchPanel,
this.mediaImageInfoPanel,
this.mediaSettingsBooklet
] );
this.$body.append( this.panels.$element );
};
/**
* Handle set events from the search tabs
*
* @param {OO.ui.CardLayout} card Current card
*/
ve.ui.MWMediaDialog.prototype.onSearchTabsSet = function ( card ) {
var name = card.getName();
this.actions.setMode( name );
switch ( name ) {
case 'search':
this.setSize( 'larger' );
break;
case 'upload':
this.setSize( 'medium' );
this.uploadPageNameSet( 'upload' );
break;
}
};
/**
* Handle panelNameSet events from the upload stack
*
* @param {OO.ui.PageLayout} page Current page
*/
ve.ui.MWMediaDialog.prototype.onMediaUploadBookletSet = function ( page ) {
this.uploadPageNameSet( page.getName() );
};
/**
* The upload booklet's page name has changed
*
* @param {string} pageName Page name
*/
ve.ui.MWMediaDialog.prototype.uploadPageNameSet = function ( pageName ) {
var imageInfo;
if ( pageName === 'insert' ) {
imageInfo = this.mediaUploadBooklet.upload.getImageInfo();
this.chooseImageInfo( imageInfo );
} else {
// Hide the tabs after the first page
this.searchTabs.toggleMenu( pageName === 'upload' );
this.actions.setMode( 'upload-' + pageName );
}
};
/**
* Handle uploadValid events
*
* @param {boolean} isValid The panel is complete and valid
*/
ve.ui.MWMediaDialog.prototype.onUploadValid = function ( isValid ) {
this.actions.setAbilities( { upload: isValid } );
};
/**
* Handle infoValid events
*
* @param {boolean} isValid The panel is complete and valid
*/
ve.ui.MWMediaDialog.prototype.onInfoValid = function ( isValid ) {
this.actions.setAbilities( { save: isValid } );
};
/**
* Build the image info panel from the information in the API.
* Use the metadata info if it exists.
* Note: Some information in the metadata object needs to be safely
* stripped from its html wrappers.
*
* @param {Object} imageinfo Image info
*/
ve.ui.MWMediaDialog.prototype.buildMediaInfoPanel = function ( imageinfo ) {
var i, newDimensions, field, isPortrait, $info, $section, windowWidth,
contentDirection = this.getFragment().getDocument().getDir(),
imageTitleText = imageinfo.title || imageinfo.canonicaltitle,
imageTitle = new OO.ui.LabelWidget( {
label: mw.Title.newFromText( imageTitleText ).getNameText()
} ),
metadata = imageinfo.extmetadata,
// Field configuration (in order)
apiDataKeysConfig = [
{
name: 'ImageDescription',
value: ve.getProp( metadata, 'ImageDescription', 'value' ),
data: {
keepOriginal: true
},
view: {
type: 'description',
primary: true,
descriptionHeight: '5em'
}
},
{
name: 'fileDetails',
data: { skipProcessing: true },
view: { icon: 'image' }
},
{
name: 'LicenseShortName',
value: ve.getProp( metadata, 'LicenseShortName', 'value' ),
data: {},
view: {
href: ve.getProp( metadata, 'LicenseUrl', 'value' ),
icon: this.getLicenseIcon( ve.getProp( metadata, 'LicenseShortName', 'value' ) )
}
},
{
name: 'Artist',
value: ve.getProp( metadata, 'Artist', 'value' ),
data: {},
view: {
// "Artist" label
label: 'visualeditor-dialog-media-info-meta-artist',
icon: 'profile'
}
},
{
name: 'Credit',
value: ve.getProp( metadata, 'Credit', 'value' ),
data: {},
view: { icon: 'profile' }
},
{
name: 'user',
value: imageinfo.user,
data: { skipProcessing: true },
view: {
icon: 'profile',
// This is 'uploaded by'
label: 'visualeditor-dialog-media-info-artist'
}
},
{
name: 'timestamp',
value: imageinfo.timestamp,
data: {
ignoreCharLimit: true
},
view: {
icon: 'clock',
label: 'visualeditor-dialog-media-info-uploaded',
isDate: true
}
},
{
name: 'DateTimeOriginal',
value: ve.getProp( metadata, 'DateTimeOriginal', 'value' ),
data: {},
view: {
icon: 'clock',
label: 'visualeditor-dialog-media-info-created'
}
},
{
name: 'moreinfo',
value: ve.msg( 'visualeditor-dialog-media-info-moreinfo' ),
data: {},
view: {
icon: 'info',
href: imageinfo.descriptionurl
}
}
],
fields = {},
// Store clean API data
apiData = {},
fileType = this.getFileType( imageinfo.url ),
$thumbContainer = $( '
' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-thumb' ),
$main = $( '
' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-main' ),
$details = $( '
' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-details' ),
$image = $( '
' );
// Main section - title
$main.append(
imageTitle.$element
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-title' )
);
// Clean data from the API responses
for ( i = 0; i < apiDataKeysConfig.length; i++ ) {
field = apiDataKeysConfig[ i ].name;
// Skip empty fields and those that are specifically configured to be skipped
if ( apiDataKeysConfig[ i ].data.skipProcessing ) {
apiData[ field ] = apiDataKeysConfig[ i ].value;
} else {
// Store a clean information from the API.
if ( apiDataKeysConfig[ i ].value ) {
apiData[ field ] = this.cleanAPIresponse( apiDataKeysConfig[ i ].value, apiDataKeysConfig[ i ].data );
}
}
}
// Add sizing info for non-audio images
if ( imageinfo.mediatype === 'AUDIO' ) {
// Label this file as an audio
apiData.fileDetails = $( '
' )
.append( ve.msg( 'visualeditor-dialog-media-info-audiofile' ) );
} else {
// Build the display for image size and type
apiData.fileDetails = $( '' )
.append(
$( '
' ).text(
imageinfo.width +
'\u00a0' +
ve.msg( 'visualeditor-dimensionswidget-times' ) +
'\u00a0' +
imageinfo.height +
ve.msg( 'visualeditor-dimensionswidget-px' )
),
$( '' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-separator' )
.text( mw.msg( 'visualeditor-dialog-media-info-separator' ) ),
$( '' ).text( fileType )
);
}
// Attach all fields in order
for ( i = 0; i < apiDataKeysConfig.length; i++ ) {
field = apiDataKeysConfig[ i ].name;
if ( apiData[ field ] ) {
$section = apiDataKeysConfig[ i ].view.primary ? $main : $details;
fields[ field ] = new ve.ui.MWMediaInfoFieldWidget( apiData[ field ], apiDataKeysConfig[ i ].view );
$section.append( fields[ field ].$element );
}
}
// Build the info panel
$info = $( '' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-info' )
.append(
$main.prop( 'dir', contentDirection ),
$details
);
// Make sure all links open in a new window
$info.find( 'a' ).prop( 'target', '_blank' );
// Initialize thumb container
$thumbContainer
.append( $image.prop( 'src', imageinfo.thumburl ) );
this.$infoPanelWrapper.append(
$thumbContainer,
$info
);
// Force a scrollbar to the screen before we measure it
this.mediaImageInfoPanel.$element.css( 'overflow-y', 'scroll' );
windowWidth = this.mediaImageInfoPanel.$element.width();
// Define thumbnail size
if ( imageinfo.mediatype === 'AUDIO' ) {
// HACK: We are getting the wrong information from the
// API about audio files. Set their thumbnail to square
newDimensions = {
width: imageinfo.thumbwidth,
height: imageinfo.thumbwidth
};
} else {
// For regular images, calculate a bigger image dimensions
newDimensions = ve.dm.MWImageNode.static.resizeToBoundingBox(
// Original image dimensions
{
width: imageinfo.width,
height: imageinfo.height
},
// Bounding box -- the size of the dialog, minus padding
{
width: windowWidth,
height: this.getBodyHeight() - 120
}
);
}
// Resize the image
$image.css( {
width: newDimensions.width,
height: newDimensions.height
} );
// Call for a bigger image
this.fetchThumbnail( imageTitleText, newDimensions )
.done( function ( thumburl ) {
if ( thumburl ) {
$image.prop( 'src', thumburl );
}
} );
isPortrait = newDimensions.width < ( windowWidth * 3 / 5 );
this.mediaImageInfoPanel.$element.toggleClass( 've-ui-mwMediaDialog-panel-imageinfo-portrait', isPortrait );
this.mediaImageInfoPanel.$element.append( this.$infoPanelWrapper );
if ( isPortrait ) {
$info.outerWidth( Math.floor( windowWidth - $thumbContainer.outerWidth( true ) - 15 ) );
}
// Initialize fields
for ( field in fields ) {
fields[ field ].initialize();
}
// Let the scrollbar appear naturally if it should
this.mediaImageInfoPanel.$element.css( 'overflow', '' );
};
/**
* Fetch a bigger image thumbnail from the API.
*
* @param {string} imageName Image source
* @param {Object} dimensions Image dimensions
* @return {jQuery.Promise} Thumbnail promise that resolves with new thumb url
*/
ve.ui.MWMediaDialog.prototype.fetchThumbnail = function ( imageName, dimensions ) {
var dialog = this,
apiObj = {
action: 'query',
prop: 'imageinfo',
indexpageids: '1',
iiprop: 'url',
titles: imageName
};
// Check cache first
if ( this.searchCache[ imageName ] ) {
return $.Deferred().resolve( this.searchCache[ imageName ] );
}
if ( dimensions.width ) {
apiObj.iiurlwidth = dimensions.width;
}
if ( dimensions.height ) {
apiObj.iiurlheight = dimensions.height;
}
return new mw.Api().get( apiObj )
.then( function ( response ) {
var thumburl = ve.getProp(
response.query.pages[ response.query.pageids[ 0 ] ],
'imageinfo',
0,
'thumburl'
);
// Cache
dialog.searchCache[ imageName ] = thumburl;
return thumburl;
} );
};
/**
* Clean the API responses and return it in plaintext. If needed, truncate.
*
* @param {string} rawResponse Raw response from the API
* @param {Object} config Configuration options
* @return {string} Plaintext clean response
*/
ve.ui.MWMediaDialog.prototype.cleanAPIresponse = function ( rawResponse, config ) {
var isTruncated, charLimit,
html = $.parseHTML( rawResponse ),
ellipsis = ve.msg( 'visualeditor-dialog-media-info-ellipsis' ),
originalText = $( '