wikimedia/mediawiki-extensions-VisualEditor

View on GitHub
modules/ve-mw/ui/dialogs/ve.ui.MWMediaDialog.js

Summary

Maintainability
F
1 wk
Test Coverage
/*!
 * 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 );