modules/ve-mw/ui/dialogs/ve.ui.MWMediaDialog.js
/*!
* VisualEditor user interface MWMediaDialog class.
*
* @copyright 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.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 = 'medium';
ve.ui.MWMediaDialog.static.actions = [
{
action: 'done',
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', 'progressive' ],
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', 'close' ],
modes: [ 'readonly', '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 () {
const rules = ve.copy( ve.init.target.constructor.static.importRules );
return ve.extendObject(
rules,
{
all: {
blacklist: ve.extendObject(
{
// Tables (but not lists) are possible in wikitext with a leading
// line break but we prevent creating these with the UI
list: true,
listItem: true,
definitionList: true,
definitionListItem: true,
table: true,
tableCaption: true,
tableSection: true,
tableRow: true,
tableCell: true,
mwTable: true,
mwTransclusionTableCell: true
},
ve.getProp( rules, 'all', 'blacklist' )
),
// Headings are also possible, but discouraged
conversions: ve.extendObject(
{
mwHeading: 'paragraph'
},
ve.getProp( rules, 'all', 'conversions' )
)
}
}
);
};
/* Methods */
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getBodyHeight = function () {
// FIXME: This should vary on panel.
return 600;
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.initialize = function () {
// Parent method
ve.ui.MWMediaDialog.super.prototype.initialize.call( this );
// Main layout
this.panels = new OO.ui.StackLayout();
// Settings panels
this.mediaSettingsLayout = new OO.ui.IndexLayout( {
classes: [ 've-ui-mwMediaDialog-panel-settings' ]
} );
this.generalSettingsPanel = new OO.ui.TabPanelLayout( 'general', {
label: ve.msg( 'visualeditor-dialog-media-page-general' )
} );
this.advancedSettingsPanel = new OO.ui.TabPanelLayout( 'advanced', {
label: ve.msg( 'visualeditor-dialog-media-page-advanced' )
} );
// General settings panel
// Filename
this.filenameFieldset = new OO.ui.FieldsetLayout( {
label: ve.msg( 'visualeditor-dialog-media-content-filename' ),
icon: 'image'
} );
// Caption
this.captionTarget = ve.init.target.createTargetWidget( {
includeCommands: this.constructor.static.includeCommands,
excludeCommands: this.constructor.static.excludeCommands,
importRules: this.constructor.static.getImportRules(),
inDialog: this.constructor.static.name,
multiline: false
} );
const captionField = new OO.ui.FieldLayout( this.captionTarget, {
align: 'top'
} );
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' ),
classes: [ 've-ui-mwMediaDialog-caption-fieldset' ]
} );
this.captionFieldset.addItems( [ captionField ] );
// Alt text
this.altTextInput = new OO.ui.MultilineTextInputWidget( {
spellcheck: true,
classes: [ 've-ui-mwMediaDialog-altText' ],
autosize: true,
rows: 1,
allowLinebreaks: false
} );
const altTextField = new OO.ui.FieldLayout( this.altTextInput, {
align: 'top'
} );
const 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' )
} );
altTextFieldset.addItems( [ altTextField ] );
// Advanced settings
// Position
this.positionSelect = new ve.ui.AlignWidget( {
dir: this.getDir()
} );
const positionSelectField = new OO.ui.FieldLayout( this.positionSelect );
this.positionCheckbox = new OO.ui.CheckboxInputWidget();
const positionCheckboxField = 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' )
} );
const 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' )
} );
positionFieldset.addItems( [
positionCheckboxField,
positionSelectField
] );
// Type
this.typeSelectDropdown = new OO.ui.DropdownWidget( { $overlay: this.$overlay } );
this.typeSelect = this.typeSelectDropdown.getMenu();
this.typeSelect.addItems( [
// TODO: Inline images require a bit of further work, will be coming soon
new OO.ui.MenuOptionWidget( {
data: 'thumb',
icon: 'imageLayoutThumbnail',
label: ve.msg( 'visualeditor-dialog-media-type-thumb' )
} ),
new OO.ui.MenuOptionWidget( {
data: 'frameless',
icon: 'imageLayoutFrameless',
label: ve.msg( 'visualeditor-dialog-media-type-frameless' )
} ),
new OO.ui.MenuOptionWidget( {
data: 'frame',
icon: 'imageLayoutFrame',
label: ve.msg( 'visualeditor-dialog-media-type-frame' )
} ),
new OO.ui.MenuOptionWidget( {
data: 'none',
icon: 'imageLayoutBasic',
label: ve.msg( 'visualeditor-dialog-media-type-none' )
} )
] );
const typeSelectField = new OO.ui.FieldLayout( this.typeSelectDropdown, {
align: 'top'
} );
this.borderCheckbox = new OO.ui.CheckboxInputWidget();
const borderField = new OO.ui.FieldLayout( this.borderCheckbox, {
align: 'inline',
label: ve.msg( 'visualeditor-dialog-media-type-border' )
} );
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' )
} );
this.typeFieldset.addItems( [
typeSelectField,
borderField
] );
// Size
this.sizeWidget = new ve.ui.MediaSizeWidget( undefined, {
dimensionsAlign: 'top'
} );
const sizeWidgetField = new OO.ui.FieldLayout( this.sizeWidget );
this.sizeFieldset = new OO.ui.FieldsetLayout( {
$overlay: this.$overlay,
label: ve.msg( 'visualeditor-dialog-media-size-section' ),
help: ve.msg( 'visualeditor-dialog-media-size-section-help' )
} );
this.sizeFieldset.addItems( [
sizeWidgetField
] );
// Search, upload and info layouts
this.mediaSearchPanel = new OO.ui.TabPanelLayout( {
classes: [ 've-ui-mwMediaDialog-panel-search' ],
scrollable: true
} );
if ( mw.ForeignStructuredUpload && mw.ForeignStructuredUpload.BookletLayout ) {
this.mediaUploadBooklet = new mw.ForeignStructuredUpload.BookletLayout( {
$overlay: this.$overlay
} );
}
this.mediaImageInfoPanel = new OO.ui.TabPanelLayout( {
classes: [ 've-ui-mwMediaDialog-panel-imageinfo' ],
scrollable: false
} );
this.$infoPanelWrapper = $( '<div>' ).addClass( 've-ui-mwMediaDialog-panel-imageinfo-wrapper' );
// Search and upload panels
this.searchTabs = new OO.ui.IndexLayout();
const searchPanel = new OO.ui.TabPanelLayout( 'search', {
label: ve.msg( 'visualeditor-dialog-media-search-tab-search' )
} );
let uploadPanel;
if ( this.mediaUploadBooklet ) {
uploadPanel = new OO.ui.TabPanelLayout( 'upload', {
label: ve.msg( 'visualeditor-dialog-media-search-tab-upload' ),
content: [ this.mediaUploadBooklet ]
} );
}
// Search widget
this.search = new mw.widgets.MediaSearchWidget( {
rowHeight: OO.ui.isMobile() ? 120 : 200
} );
// 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.getQuery().connect( this, { change: 'onSearchQueryChange' } );
this.search.getQuery().$indicator.on( 'mousedown', this.onSearchQueryClear.bind( this ) );
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' } );
if ( this.mediaUploadBooklet ) {
this.mediaUploadBooklet.connect( this, {
set: 'onMediaUploadBookletSet',
uploadValid: 'onUploadValid',
infoValid: 'onInfoValid'
} );
}
// Append panels
searchPanel.$element.append( this.search.$element );
this.searchTabs.addTabPanels( [ searchPanel ] );
if ( this.mediaUploadBooklet ) {
this.searchTabs.addTabPanels( [ uploadPanel ] );
}
this.mediaSearchPanel.$element.append(
this.searchTabs.$element
);
this.generalSettingsPanel.$element.append(
this.filenameFieldset.$element,
this.captionFieldset.$element,
altTextFieldset.$element
);
this.advancedSettingsPanel.$element.append(
positionFieldset.$element,
this.typeFieldset.$element,
this.sizeFieldset.$element
);
this.mediaSettingsLayout.addTabPanels( [
this.generalSettingsPanel,
this.advancedSettingsPanel
] );
this.panels.addItems( [
this.mediaSearchPanel,
this.mediaImageInfoPanel,
this.mediaSettingsLayout
] );
this.$body.append( this.panels.$element );
};
/**
* Handle set events from the search tabs
*
* @param {OO.ui.TabPanelLayout} tabPanel Current tabPanel
*/
ve.ui.MWMediaDialog.prototype.onSearchTabsSet = function ( tabPanel ) {
const name = tabPanel.getName();
this.actions.setMode( name );
switch ( name ) {
case 'search':
this.setSize( 'larger' );
break;
case 'upload':
// Initialize and reset the upload booklet if it hasn't
// been initiailized since setup.
if ( !this.mediaUploadBookletInit ) {
this.mediaUploadBookletInit = true;
this.mediaUploadBooklet.initialize();
}
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 ) {
if ( pageName === 'insert' ) {
const 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 ) {
const 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' ),
format: 'html',
view: {
type: 'description',
primary: true,
descriptionHeight: '5em'
}
},
{
name: '$fileDetails',
// Real value is set later
value: '',
format: 'html',
view: { icon: 'image' }
},
{
name: 'LicenseShortName',
value: ve.getProp( metadata, 'LicenseShortName', 'value' ),
format: 'html-remove-formatting',
view: {
href: ve.getProp( metadata, 'LicenseUrl', 'value' ),
icon: this.getLicenseIcon( ve.getProp( metadata, 'LicenseShortName', 'value' ) )
}
},
{
name: 'Artist',
value: ve.getProp( metadata, 'Artist', 'value' ),
format: 'html-remove-formatting',
view: {
// "Artist" label
labelMsg: 'visualeditor-dialog-media-info-meta-artist',
icon: 'userAvatar'
}
},
{
name: 'Credit',
value: ve.getProp( metadata, 'Credit', 'value' ),
format: 'html-remove-formatting',
view: { icon: 'userAvatar' }
},
{
name: 'user',
value: imageinfo.user,
format: 'plaintext',
view: {
icon: 'userAvatar',
// This is 'uploaded by'
labelMsg: 'visualeditor-dialog-media-info-artist'
}
},
{
name: 'timestamp',
value: imageinfo.timestamp,
format: 'plaintext',
view: {
icon: 'clock',
labelMsg: 'visualeditor-dialog-media-info-uploaded',
isDate: true
}
},
{
name: 'DateTimeOriginal',
value: ve.getProp( metadata, 'DateTimeOriginal', 'value' ),
format: 'html-remove-formatting',
view: {
icon: 'clock',
labelMsg: 'visualeditor-dialog-media-info-created'
}
},
{
name: 'moreinfo',
value: ve.msg( 'visualeditor-dialog-media-info-moreinfo' ),
format: 'plaintext',
view: {
icon: 'info',
href: imageinfo.descriptionurl
}
}
],
fields = {},
// Store clean API data
apiData = {},
fileType = this.getFileType( imageinfo.url ),
$thumbContainer = $( '<div>' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-thumb' ),
$main = $( '<div>' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-main' ),
$details = $( '<div>' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-details' ),
$image = $( '<img>' );
// Main section - title
$main.append(
imageTitle.$element
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-title' )
);
// Clean data from the API responses
for ( let i = 0; i < apiDataKeysConfig.length; i++ ) {
const field = apiDataKeysConfig[ i ].name;
if ( apiDataKeysConfig[ i ].format === 'html' ) {
apiData[ field ] = new OO.ui.HtmlSnippet( apiDataKeysConfig[ i ].value );
} else if ( apiDataKeysConfig[ i ].format === 'html-remove-formatting' ) {
apiData[ field ] = this.cleanAPIresponse( apiDataKeysConfig[ i ].value );
} else if ( apiDataKeysConfig[ i ].format === 'plaintext' ) {
apiData[ field ] = apiDataKeysConfig[ i ].value;
} else {
throw new Error( 'Unexpected metadata field format' );
}
}
// Add sizing info for non-audio images
if ( imageinfo.mediatype === 'AUDIO' ) {
// Label this file as an audio
apiData.$fileDetails = $( '<span>' )
.text( ve.msg( 'visualeditor-dialog-media-info-audiofile' ) );
} else {
// Build the display for image size and type
apiData.$fileDetails = $( '<div>' )
.append(
$( '<span>' ).text(
imageinfo.width +
'\u00a0' +
ve.msg( 'visualeditor-dimensionswidget-times' ) +
'\u00a0' +
imageinfo.height +
ve.msg( 'visualeditor-dimensionswidget-px' )
),
$( '<span>' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-separator' )
.text( ve.msg( 'visualeditor-dialog-media-info-separator' ) ),
$( '<span>' ).text( fileType )
);
}
// Attach all fields in order
for ( let i = 0; i < apiDataKeysConfig.length; i++ ) {
const field = apiDataKeysConfig[ i ].name;
if ( apiData[ field ] ) {
const $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
const $info = $( '<div>' )
.addClass( 've-ui-mwMediaDialog-panel-imageinfo-info' )
.append(
$main.prop( 'dir', contentDirection ),
$details
);
ve.targetLinksToNewWindow( $info[ 0 ] );
// 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' );
const windowWidth = this.mediaImageInfoPanel.$element.width();
// Define thumbnail size
let newDimensions;
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( ( thumburl ) => {
if ( thumburl ) {
$image.prop( 'src', thumburl );
}
} );
const 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 ( const 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 ) {
// Check cache first
if ( this.searchCache[ imageName ] ) {
return ve.createDeferred().resolve( this.searchCache[ imageName ] );
}
const params = {
action: 'query',
prop: 'imageinfo',
iiprop: 'url',
titles: imageName
};
if ( dimensions.width ) {
params.iiurlwidth = dimensions.width;
}
if ( dimensions.height ) {
params.iiurlheight = dimensions.height;
}
return ve.init.target.getContentApi( this.getFragment().getDocument() ).get( params )
.then( ( response ) => {
const thumburl = ve.getProp( response.query.pages[ 0 ], 'imageinfo', 0, 'thumburl' );
// Cache
this.searchCache[ imageName ] = thumburl;
return thumburl;
} );
};
/**
* Clean the API responses and return it in plaintext. If needed, truncate.
*
* @param {string} html Raw response from the API
* @return {string} Plaintext clean response
*/
ve.ui.MWMediaDialog.prototype.cleanAPIresponse = function ( html ) {
let text = $( $.parseHTML( html ) ).text();
// Check if the string should be truncated
const charLimit = 50;
if ( text.length > charLimit ) {
const ellipsis = ve.msg( 'visualeditor-dialog-media-info-ellipsis' );
text = text.slice( 0, charLimit ) + ellipsis;
}
return text;
};
/**
* Get the file type from the suffix of the url
*
* @param {string} url Full file url
* @return {string} File type
*/
ve.ui.MWMediaDialog.prototype.getFileType = function ( url ) {
// TODO: Validate these types, and work with icons
// SVG, PNG, JPEG, GIF, TIFF, XCF;
// OGA, OGG, MIDI, WAV;
// WEBM, OGV, OGX;
// APNG;
// PDF, DJVU
return url.split( '.' ).pop().toUpperCase();
};
/**
* Get the proper icon for the license if it is recognized
* or general info icon if it is not recognized.
*
* @param {string} license License short name
* @return {string} Icon name
*/
ve.ui.MWMediaDialog.prototype.getLicenseIcon = function ( license ) {
if ( !license ) {
return 'info';
}
const normalized = license.toLowerCase().replace( /[-_]/g, ' ' );
// FIXME: Structured data from Commons will make this properly
// multilingual. For now, this is the limit of what is sensible.
if ( /^((cc )?pd|public domain)/.test( normalized ) ) {
return 'public-domain';
} else if ( /^cc (by|sa)?/.test( normalized ) ) {
return 'logoCC';
} else {
return 'info';
}
};
/**
* Handle search results choose event.
*
* @param {mw.widgets.MediaResultWidget} item Chosen item
*/
ve.ui.MWMediaDialog.prototype.onSearchResultsChoose = function ( item ) {
this.chooseImageInfo( item.getData() );
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-choose-image'
} );
};
/**
* Handle query change events from the search input widget
*
* @param {string} query
*/
ve.ui.MWMediaDialog.prototype.onSearchQueryChange = function ( query ) {
if ( query === '' ) {
return;
}
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-change-query'
} );
};
/**
* Handle clearing of search query by user clicking on indicator
*/
ve.ui.MWMediaDialog.prototype.onSearchQueryClear = function () {
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-clear-query'
} );
};
/**
* Choose image info for editing
*
* @param {Object} info Image info
*/
ve.ui.MWMediaDialog.prototype.chooseImageInfo = function ( info ) {
this.$infoPanelWrapper.empty();
// Switch panels
this.selectedImageInfo = info;
this.switchPanels( 'imageInfo' );
// Build info panel
this.buildMediaInfoPanel( info );
};
/**
* Handle new image being chosen.
*
* @param {mw.widgets.MediaResultWidget|null} item Selected item
*/
ve.ui.MWMediaDialog.prototype.confirmSelectedImage = function () {
const obj = {},
info = this.selectedImageInfo;
if ( info ) {
const imageTitleText = info.title || info.canonicaltitle;
// Run title through mw.Title so the File: prefix is localised
const title = mw.Title.newFromText( imageTitleText ).getPrefixedText();
if ( !this.imageModel ) {
// Create a new image model based on default attributes
this.imageModel = ve.dm.MWImageModel.static.newFromImageAttributes(
{
// Per https://www.mediawiki.org/w/?diff=931265&oldid=prev
href: './' + title,
src: info.url,
resource: './' + title,
width: info.thumbwidth,
height: info.thumbheight,
mediaType: info.mediatype,
type: 'thumb',
align: 'default',
defaultSize: true,
imageClassAttr: 'mw-file-element'
},
this.getFragment().getDocument()
);
this.attachImageModel();
this.resetCaption();
} else {
// Update the current image model with the new image source
this.imageModel.changeImageSource(
{
mediaType: info.mediatype,
href: './' + title,
src: info.url,
resource: './' + title
},
info
);
this.updateFilenameFieldset();
}
// Cache
// We're trimming the stored data down to be consistent with what
// ImageInfoCache.getRequestPromise fetches.
obj[ imageTitleText ] = {
size: info.size,
width: info.width,
height: info.height,
mediatype: info.mediatype
};
ve.init.platform.imageInfoCache.set( obj );
this.checkChanged();
this.switchPanels( 'edit' );
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-confirm-image'
} );
}
};
/**
* Update the filename fieldset (link to media page)
*/
ve.ui.MWMediaDialog.prototype.updateFilenameFieldset = function () {
const title = mw.Title.newFromText( mw.libs.ve.normalizeParsoidResourceName( this.imageModel.getResourceName() ) );
this.filenameFieldset.setLabel(
$( '<span>' ).append(
$( document.createTextNode( this.imageModel.getFilename() + ' ' ) ),
$( '<a>' )
.addClass( 've-ui-mwMediaDialog-description-link' )
.attr( 'href', title.getUrl() )
.attr( 'target', '_blank' )
.attr( 'rel', 'noopener' )
.text( ve.msg( 'visualeditor-dialog-media-content-description-link' ) )
)
);
};
/**
* Handle image model alignment change
*
* @param {string} alignment Image alignment
*/
ve.ui.MWMediaDialog.prototype.onImageModelAlignmentChange = function ( alignment ) {
alignment = alignment || 'none';
// Select the item without triggering the 'choose' event
this.positionSelect.selectItemByData( alignment !== 'none' ? alignment : undefined );
this.positionCheckbox.setSelected( alignment !== 'none' );
this.checkChanged();
};
/**
* Handle image model type change
*
* @param {string} type Image type
*/
ve.ui.MWMediaDialog.prototype.onImageModelTypeChange = function ( type ) {
this.typeSelect.selectItemByData( type );
this.borderCheckbox.setDisabled(
!this.imageModel.isBorderable()
);
this.borderCheckbox.setSelected(
this.imageModel.isBorderable() && this.imageModel.hasBorder()
);
this.checkChanged();
};
/**
* Handle change event on the positionCheckbox element.
*
* @param {boolean} isSelected Checkbox status
*/
ve.ui.MWMediaDialog.prototype.onPositionCheckboxChange = function ( isSelected ) {
const currentModelAlignment = this.imageModel.getAlignment();
this.positionSelect.setDisabled( !isSelected );
this.checkChanged();
// Only update the model if the current value is different than that
// of the image model
if (
( currentModelAlignment === 'none' && isSelected ) ||
( currentModelAlignment !== 'none' && !isSelected )
) {
if ( isSelected ) {
// Picking a floating alignment value will create a block image
// no matter what the type is, so in here we want to calculate
// the default alignment of a block to set as our initial alignment
// in case the checkbox is clicked but there was no alignment set
// previously.
const newPositionValue = this.imageModel.getDefaultDir( 'mwBlockImage' );
this.imageModel.setAlignment( newPositionValue );
} else {
// If we're unchecking the box, always set alignment to none and unselect the position widget
this.imageModel.setAlignment( 'none' );
}
}
};
/**
* Handle change event on the positionCheckbox element.
*
* @param {boolean} isSelected Checkbox status
*/
ve.ui.MWMediaDialog.prototype.onBorderCheckboxChange = function ( isSelected ) {
// Only update if the value is different than the model
if ( this.imageModel.hasBorder() !== isSelected ) {
// Update the image model
this.imageModel.toggleBorder( isSelected );
this.checkChanged();
}
};
/**
* Handle change event on the positionSelect element.
*
* @param {OO.ui.ButtonOptionWidget} item Selected item
*/
ve.ui.MWMediaDialog.prototype.onPositionSelectChoose = function ( item ) {
const position = item.getData();
// Only update if the value is different than the model
if ( this.imageModel.getAlignment() !== position ) {
this.imageModel.setAlignment( position );
this.checkChanged();
}
};
/**
* Handle change event on the typeSelect element.
*
* @param {OO.ui.MenuOptionWidget} item Selected item
*/
ve.ui.MWMediaDialog.prototype.onTypeSelectChoose = function ( item ) {
const type = item.getData();
// Only update if the value is different than the model
if ( this.imageModel.getType() !== type ) {
this.imageModel.setType( type );
this.checkChanged();
}
// If type is 'frame', custom size is ignored
if ( type === 'frame' ) {
this.sizeWidget.setSizeType( 'default' );
}
};
/**
* Handle changeSizeType events from the MediaSizeWidget
*
* @param {string} sizeType Size type
*/
ve.ui.MWMediaDialog.prototype.onChangeSizeType = function ( sizeType ) {
// type=frame is not resizeable, so change it to type=thumb
if ( sizeType === 'custom' && this.imageModel.getType() === 'frame' ) {
this.imageModel.setType( 'thumb' );
}
this.checkChanged();
};
/**
* Respond to change in alternate text
*
* @param {string} text New alternate text
*/
ve.ui.MWMediaDialog.prototype.onAlternateTextChange = function ( text ) {
this.imageModel.setAltText( text );
this.checkChanged();
};
/**
* When changes occur, enable the apply button.
*/
ve.ui.MWMediaDialog.prototype.checkChanged = function () {
let captionChanged = false;
// Only check 'changed' status after the model has finished
// building itself
if ( !this.isSettingUpModel ) {
captionChanged = !!this.captionTarget && this.captionTarget.hasBeenModified();
if (
this.imageModel &&
// Activate or deactivate the apply/insert buttons
// Make sure sizes are valid first
this.sizeWidget.isValid() &&
(
// Check that the model or caption changed
this.isInsertion ||
captionChanged ||
this.imageModel.hasBeenModified()
)
) {
this.actions.setAbilities( { insert: true, done: true } );
} else {
this.actions.setAbilities( { insert: false, done: false } );
}
}
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getSetupProcess = function ( data ) {
return ve.ui.MWMediaDialog.super.prototype.getSetupProcess.call( this, data )
.next( () => {
const isReadOnly = this.isReadOnly();
// Set language for search results
this.search.setLang( this.getFragment().getDocument().getLang() );
if ( this.selectedNode ) {
this.isInsertion = false;
// Create image model
this.imageModel = ve.dm.MWImageModel.static.newFromImageNode( this.selectedNode );
this.attachImageModel();
if ( !this.imageModel.isDefaultSize() ) {
// To avoid dirty diff in case where only the image changes,
// we will store the initial bounding box, in case the image
// is not defaultSize
this.imageModel.setBoundingBox( this.imageModel.getCurrentDimensions() );
}
// Store initial hash to compare against
this.imageModel.storeInitialHash( this.imageModel.getHashObject() );
} else {
this.isInsertion = true;
}
this.search.setup();
// Try to populate with user uploads
this.search.queryMediaQueue();
this.resetCaption();
this.altTextInput.setReadOnly( isReadOnly );
this.positionCheckbox.setDisabled( isReadOnly );
// TODO: This widget is not readable when disabled
this.positionSelect.setDisabled( isReadOnly );
this.typeSelectDropdown.setDisabled( isReadOnly );
this.borderCheckbox.setDisabled( isReadOnly );
this.sizeWidget.setDisabled( isReadOnly );
// Pass `true` to avoid focussing. If we focus the image caption widget during dialog
// opening, and it wants to display a context menu, it will be mispositioned.
this.switchPanels( this.selectedNode ? 'edit' : 'search', true );
this.actions.setAbilities( { upload: false, save: false, insert: false, done: false } );
this.mediaUploadBookletInit = false;
if ( data.file && this.mediaUploadBooklet ) {
this.searchTabs.setTabPanel( 'upload' );
this.mediaUploadBooklet.setFile( data.file );
}
} );
};
/**
* Switch between the edit and insert/search panels
*
* @param {string} panel Panel name
* @param {boolean} [noFocus=false] Do not put focus into the default field of the panel
*/
ve.ui.MWMediaDialog.prototype.switchPanels = function ( panel, noFocus ) {
switch ( panel ) {
case 'edit':
this.setSize( this.constructor.static.size );
// Set the edit panel
this.panels.setItem( this.mediaSettingsLayout );
// Focus the general settings page
this.mediaSettingsLayout.setTabPanel( 'general' );
// Parent functionality (edit/insert/readonly)
this.actions.setMode( this.getMode() );
if ( !noFocus ) {
// Focus the caption surface
this.captionTarget.focus();
}
// Auto-sized alt text field is populated while hidden,
// so force a manual resize now.
this.altTextInput.adjustSize( true );
break;
case 'search':
this.setSize( 'larger' );
this.selectedImageInfo = null;
// Set the edit panel
this.panels.setItem( this.mediaSearchPanel );
this.searchTabs.setTabPanel( 'search' );
this.searchTabs.toggleMenu( true );
this.actions.setMode( this.imageModel ? 'change' : 'select' );
if ( !noFocus ) {
this.search.getQuery().focus().select();
}
// Layout pending items
this.search.runLayoutQueue();
break;
default:
case 'imageInfo':
this.setSize( 'larger' );
// Hide/show buttons
this.actions.setMode( 'info' );
// Hide/show the panels
this.panels.setItem( this.mediaImageInfoPanel );
break;
}
this.currentPanel = panel || 'imageinfo';
};
/**
* Attach the image model to the dialog
*/
ve.ui.MWMediaDialog.prototype.attachImageModel = function () {
if ( this.imageModel ) {
this.imageModel.disconnect( this );
this.sizeWidget.disconnect( this );
}
// Events
this.imageModel.connect( this, {
alignmentChange: 'onImageModelAlignmentChange',
typeChange: 'onImageModelTypeChange',
sizeDefaultChange: 'checkChanged'
} );
// Set up
// Ignore the following changes in validation while we are
// setting up the initial tools according to the model state
this.isSettingUpModel = true;
// Filename
this.updateFilenameFieldset();
// Size widget
this.sizeWidget.setScalable( this.imageModel.getScalable() );
this.sizeWidget.connect( this, {
changeSizeType: 'onChangeSizeType',
change: 'checkChanged',
valid: 'checkChanged'
} );
// Initialize size
this.sizeWidget.setSizeType( this.imageModel.isDefaultSize() ? 'default' : 'custom' );
// Update default dimensions
this.sizeWidget.updateDefaultDimensions();
// Set initial alt text
this.altTextInput.setValue( this.imageModel.getAltText() );
// Set initial alignment
this.positionSelect.setDisabled( !this.imageModel.isAligned() );
this.positionSelect.selectItemByData( this.imageModel.isAligned() && this.imageModel.getAlignment() );
this.positionCheckbox.setSelected( this.imageModel.isAligned() );
// Border flag
this.borderCheckbox.setDisabled( !this.imageModel.isBorderable() );
this.borderCheckbox.setSelected( this.imageModel.isBorderable() && this.imageModel.hasBorder() );
// Type select
this.typeSelect.selectItemByData( this.imageModel.getType() || 'none' );
this.isSettingUpModel = false;
};
/**
* Reset the caption surface
*/
ve.ui.MWMediaDialog.prototype.resetCaption = function () {
const doc = this.getFragment().getDocument();
// Get existing caption. We only do this in setup, because the caption
// should not reset to original if the image is replaced or edited.
//
// If the selected node is a block image and the caption already exists,
// store the initial caption and set it as the caption document
if (
this.imageModel &&
this.selectedNode &&
this.selectedNode.getDocument() &&
this.selectedNode instanceof ve.dm.MWBlockImageNode
) {
const captionNode = this.selectedNode.getCaptionNode();
if ( captionNode && captionNode.getLength() > 0 ) {
this.imageModel.setCaptionDocument(
this.selectedNode.getDocument().cloneFromRange( captionNode.getRange() )
);
}
}
let captionDocument;
if ( this.imageModel ) {
captionDocument = this.imageModel.getCaptionDocument();
} else {
captionDocument = doc.cloneWithData( [
{ type: 'paragraph', internal: { generated: 'wrapper' } },
{ type: '/paragraph' },
{ type: 'internalList' },
{ type: '/internalList' }
] );
}
// Set document
this.captionTarget.setDocument( captionDocument );
this.captionTarget.setReadOnly( this.isReadOnly() );
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getReadyProcess = function ( data ) {
return ve.ui.MWMediaDialog.super.prototype.getReadyProcess.call( this, data )
.next( () => {
if ( !data.file ) {
this.switchPanels( this.selectedNode ? 'edit' : 'search' );
}
// Revalidate size
this.sizeWidget.validateDimensions();
} );
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getTeardownProcess = function ( data ) {
return ve.ui.MWMediaDialog.super.prototype.getTeardownProcess.call( this, data )
.first( () => {
this.mediaSettingsLayout.resetScroll();
// Cleanup
this.search.getQuery().setValue( '' );
this.search.teardown();
if ( this.imageModel ) {
this.imageModel.disconnect( this );
this.sizeWidget.disconnect( this );
}
this.captionTarget.clear();
this.imageModel = null;
} );
};
/**
* @inheritdoc
*/
ve.ui.MWMediaDialog.prototype.getActionProcess = function ( action ) {
let handler;
switch ( action ) {
case 'change':
handler = function () {
this.switchPanels( 'search' );
};
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-change-image'
} );
break;
case 'back':
handler = function () {
this.switchPanels( 'edit' );
};
break;
case 'choose':
handler = function () {
this.confirmSelectedImage();
this.switchPanels( 'edit' );
};
break;
case 'cancelchoose':
handler = function () {
this.switchPanels( 'search' );
};
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-change-image'
} );
break;
case 'cancelupload':
handler = function () {
this.searchTabs.setTabPanel( 'upload' );
this.searchTabs.toggleMenu( true );
return this.mediaUploadBooklet.initialize();
};
break;
case 'upload':
ve.track( 'activity.' + this.constructor.static.name, {
action: 'search-upload-image'
} );
return new OO.ui.Process( this.mediaUploadBooklet.uploadFile() );
case 'save':
return new OO.ui.Process( this.mediaUploadBooklet.saveFile() );
case 'done':
case 'insert':
handler = function () {
const surfaceModel = this.getFragment().getSurface();
// Update from the form
this.imageModel.setAltText( this.altTextInput.getValue() );
this.imageModel.setCaptionDocument(
this.captionTarget.getSurface().getModel().getDocument()
);
if (
// There was an initial node
this.selectedNode &&
// And we didn't change the image type block/inline or vice versa
this.selectedNode.type === this.imageModel.getImageNodeType() &&
// And we didn't change the image itself
this.selectedNode.getAttribute( 'src' ) ===
this.imageModel.getImageSource()
) {
// We only need to update the attributes of the current node
this.imageModel.updateImageNode( this.selectedNode, surfaceModel );
} else {
// Replacing an image or inserting a brand new one
this.fragment = this.imageModel.insertImageNode( this.getFragment() );
}
this.close( { action: action } );
};
break;
default:
return ve.ui.MWMediaDialog.super.prototype.getActionProcess.call( this, action );
}
return new OO.ui.Process( handler, this );
};
/* Registration */
ve.ui.windowFactory.register( ve.ui.MWMediaDialog );