/*! * VisualEditor UserInterface MWCategoryWidget class. * * @copyright 2011-2019 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /** * Creates an ve.ui.MWCategoryWidget object. * * @class * @abstract * @extends OO.ui.Widget * @mixins OO.ui.mixin.GroupElement * @mixins OO.ui.mixin.DraggableGroupElement * * @constructor * @param {Object} [config] Configuration options * @cfg {jQuery} [$overlay] Overlay to render dropdowns in */ ve.ui.MWCategoryWidget = function VeUiMWCategoryWidget( config ) { var categoryNamespace = mw.config.get( 'wgNamespaceIds' ).category; // Config initialization config = config || {}; // Parent constructor ve.ui.MWCategoryWidget.super.call( this, config ); // Mixin constructors OO.ui.mixin.GroupElement.call( this, config ); OO.ui.mixin.DraggableGroupElement.call( this, $.extend( {}, config, { orientation: 'horizontal' } ) ); // Properties this.categories = {}; // Source -> target this.categoryRedirects = {}; // Title cache - will contain entries even if title is already normalized this.normalizedTitles = {}; this.popup = new ve.ui.MWCategoryPopupWidget(); this.input = new ve.ui.MWCategoryInputWidget( this, { $overlay: config.$overlay } ); this.forceCapitalization = mw.config.get( 'wgCaseSensitiveNamespaces' ).indexOf( categoryNamespace ) === -1; this.categoryPrefix = mw.config.get( 'wgFormattedNamespaces' )[ categoryNamespace ] + ':'; // Events this.input.connect( this, { choose: 'onInputChoose' } ); this.popup.connect( this, { removeCategory: 'onRemoveCategory', updateSortkey: 'onUpdateSortkey' } ); this.connect( this, { drag: 'onDrag' } ); // Initialization this.$element.addClass( 've-ui-mwCategoryWidget' ) .append( this.$group.addClass( 've-ui-mwCategoryWidget-items' ).append( this.input.$element ), this.popup.$element, $( '
' ).css( 'clear', 'both' ) ); }; /* Inheritance */ OO.inheritClass( ve.ui.MWCategoryWidget, OO.ui.Widget ); OO.mixinClass( ve.ui.MWCategoryWidget, OO.ui.mixin.GroupElement ); OO.mixinClass( ve.ui.MWCategoryWidget, OO.ui.mixin.DraggableGroupElement ); /* Events */ /** * @event newCategory * @param {Object} item Category item * @param {string} item.name Fully prefixed category name * @param {string} item.value Category value (name without prefix) * @param {Object} item.metaItem Category meta item * @param {ve.dm.MetaItem} [beforeCategory] Insert after this category; if unset, insert at the end */ /** * @event updateSortkey * @param {Object} item Category item * @param {string} item.name Fully prefixed category name * @param {string} item.value Category value (name without prefix) * @param {Object} item.metaItem Category meta item */ /* Methods */ /** * Handle input 'choose' event. * * @param {OO.ui.MenuOptionWidget} item Selected item */ ve.ui.MWCategoryWidget.prototype.onInputChoose = function ( item ) { var categoryItem, value = item.getData(), widget = this; if ( value && value !== '' ) { // Add new item categoryItem = this.getCategoryItemFromValue( value ); this.queryCategoryStatus( [ categoryItem.name ] ).done( function () { // Remove existing items by name var toRemove = mw.Title.newFromText( categoryItem.name ).getMainText(); if ( Object.prototype.hasOwnProperty.call( widget.categories, toRemove ) ) { widget.categories[ toRemove ].metaItem.remove(); } categoryItem.name = widget.normalizedTitles[ categoryItem.name ]; widget.emit( 'newCategory', categoryItem ); } ); } }; /** * Get a category item. * * @param {string} value Category name * @return {Object} Category item with name, value and metaItem properties */ ve.ui.MWCategoryWidget.prototype.getCategoryItemFromValue = function ( value ) { var title; // Normalize title = mw.Title.newFromText( this.categoryPrefix + value ); if ( title ) { return { name: title.getPrefixedText(), value: title.getMainText(), metaItem: {} }; } if ( this.forceCapitalization ) { value = value.slice( 0, 1 ).toUpperCase() + value.slice( 1 ); } return { name: this.categoryPrefix + value, value: value, metaItem: {} }; }; /** * Focus the widget */ ve.ui.MWCategoryWidget.prototype.focus = function () { this.input.$input[ 0 ].focus(); }; /** * @param {ve.ui.MWCategoryItemWidget} item Item that was moved * @param {number} newIndex The new index of the item */ ve.ui.MWCategoryWidget.prototype.onDrag = function () { this.fitInput(); }; /** * @inheritdoc OO.ui.mixin.DraggableGroupElement */ ve.ui.MWCategoryWidget.prototype.reorder = function ( item, newIndex ) { // Compute beforeCategory before removing, otherwise newIndex // could be off by one var beforeCategory = this.items[ newIndex ] && this.items[ newIndex ].metaItem; if ( Object.prototype.hasOwnProperty.call( this.categories, item.value ) ) { this.categories[ item.value ].metaItem.remove(); } this.emit( 'newCategory', item, beforeCategory ); }; /** * Removes category from model. * * @method * @param {string} name Removed category name */ ve.ui.MWCategoryWidget.prototype.onRemoveCategory = function ( name ) { this.categories[ name ].metaItem.remove(); delete this.categories[ name ]; }; /** * Update sortkey value, emit updateSortkey event * * @method * @param {string} name * @param {string} value */ ve.ui.MWCategoryWidget.prototype.onUpdateSortkey = function ( name, value ) { this.categories[ name ].sortKey = value; this.emit( 'updateSortkey', this.categories[ name ] ); }; /** * @inheritdoc */ ve.ui.MWCategoryWidget.prototype.clearItems = function () { OO.ui.mixin.GroupElement.prototype.clearItems.call( this ); this.categories = {}; }; /** * Toggles popup menu per category item * * @param {Object} item */ ve.ui.MWCategoryWidget.prototype.onTogglePopupMenu = function ( item ) { // Close open popup. if ( item.value !== this.popup.category ) { this.popup.openPopup( item ); } else { // Handle toggle this.popup.closePopup(); } }; /** * Set the default sort key. * * @param {string} value Default sort key value */ ve.ui.MWCategoryWidget.prototype.setDefaultSortKey = function ( value ) { this.popup.setDefaultSortKey( value ); }; /** * Get list of category names. * * @method * @return {string[]} List of category names */ ve.ui.MWCategoryWidget.prototype.getCategories = function () { return Object.keys( this.categories ); }; /** * Starts a request to update the link cache's hidden and missing status for * the given titles, following normalisation responses as necessary. * * @param {string[]} categoryNames * @return {jQuery.Promise} */ ve.ui.MWCategoryWidget.prototype.queryCategoryStatus = function ( categoryNames ) { var widget = this, promises = [], index = 0, batchSize = 50, categoryNamesToQuery = []; // Get rid of any we already know the hidden status of, or have an entry // if normalizedTitles (i.e. have been fetched before) categoryNamesToQuery = categoryNames.filter( function ( name ) { var cacheEntry; if ( widget.normalizedTitles[ name ] ) { return false; } cacheEntry = ve.init.platform.linkCache.getCached( name ); if ( cacheEntry && cacheEntry.hidden ) { // As we aren't doing an API request for this category, mark it in the cache. widget.normalizedTitles[ name ] = name; return false; } return true; } ); if ( !categoryNamesToQuery.length ) { return $.Deferred().resolve( {} ).promise(); } // Batch this up into groups of 50 while ( index < categoryNamesToQuery.length ) { promises.push( ve.init.target.getContentApi().get( { action: 'query', prop: 'pageprops', titles: categoryNamesToQuery.slice( index, index + batchSize ), ppprop: 'hiddencat', redirects: '' } ).then( function ( result ) { var linkCacheUpdate = {}, normalizedTitles = {}; if ( result && result.query && result.query.pages ) { // eslint-disable-next-line no-jquery/no-each-util $.each( result.query.pages, function ( index, pageInfo ) { linkCacheUpdate[ pageInfo.title ] = { missing: Object.prototype.hasOwnProperty.call( pageInfo, 'missing' ), hidden: pageInfo.pageprops && Object.prototype.hasOwnProperty.call( pageInfo.pageprops, 'hiddencat' ) }; } ); } if ( result && result.query && result.query.redirects ) { // eslint-disable-next-line no-jquery/no-each-util $.each( result.query.redirects, function ( index, redirectInfo ) { widget.categoryRedirects[ redirectInfo.from ] = redirectInfo.to; } ); } ve.init.platform.linkCache.set( linkCacheUpdate ); if ( result.query && result.query.normalized ) { // eslint-disable-next-line no-jquery/no-each-util $.each( result.query.normalized, function ( index, normalisation ) { normalizedTitles[ normalisation.from ] = normalisation.to; } ); } categoryNames.forEach( function ( name ) { widget.normalizedTitles[ name ] = normalizedTitles[ name ] || name; } ); } ) ); index += batchSize; } return $.when.apply( $, promises ); }; /** * Adds category items. * * @method * @param {Object[]} items Items to add * @param {number} [index] Index to insert items after * @return {jQuery.Promise} */ ve.ui.MWCategoryWidget.prototype.addItems = function ( items, index ) { var i, len, item, categoryItem, hadFocus, categoryItems = [], existingCategoryItems = [], // eslint-disable-next-line no-jquery/no-map-util categoryNames = $.map( items, function ( item ) { return item.name; } ), widget = this; return this.queryCategoryStatus( categoryNames ).then( function () { var itemTitle, config, cachedData, checkValueMatches = function ( existingCategoryItem ) { return config.item.value === existingCategoryItem.value; }; for ( i = 0, len = items.length; i < len; i++ ) { item = items[ i ]; item.name = widget.normalizedTitles[ item.name ]; itemTitle = new mw.Title( item.name, mw.config.get( 'wgNamespaceIds' ).category ); // Create a widget using the item data config = { item: item }; if ( Object.prototype.hasOwnProperty.call( widget.categoryRedirects, itemTitle.getPrefixedText() ) ) { config.redirectTo = new mw.Title( widget.categoryRedirects[ itemTitle.getPrefixedText() ], mw.config.get( 'wgNamespaceIds' ).category ).getMainText(); cachedData = ve.init.platform.linkCache.getCached( widget.categoryRedirects[ itemTitle.getPrefixedText() ] ); } else { cachedData = ve.init.platform.linkCache.getCached( item.name ); } config.hidden = cachedData.hidden; config.missing = cachedData.missing; categoryItem = new ve.ui.MWCategoryItemWidget( config ); categoryItem.connect( widget, { togglePopupMenu: 'onTogglePopupMenu' } ); // Index item widget.categories[ itemTitle.getMainText() ] = categoryItem; // Copy sortKey from old item when "moving" existingCategoryItems = widget.items.filter( checkValueMatches ); if ( existingCategoryItems.length ) { // There should only be one element in existingCategoryItems categoryItem.sortKey = existingCategoryItems[ 0 ].sortKey; widget.removeItems( [ existingCategoryItems[ 0 ] ] ); } categoryItems.push( categoryItem ); } OO.ui.mixin.DraggableGroupElement.prototype.addItems.call( widget, categoryItems, index ); // Ensure the input remains the last item in the list, and preserve focus hadFocus = widget.getElementDocument().activeElement === widget.input.$input[ 0 ]; widget.$group.append( widget.input.$element ); if ( hadFocus ) { widget.input.$input[ 0 ].focus(); } widget.fitInput(); } ); }; /** * @inheritdoc */ ve.ui.MWCategoryWidget.prototype.removeItems = function ( items ) { var i, len, categoryItem; for ( i = 0, len = items.length; i < len; i++ ) { categoryItem = items[ i ]; if ( categoryItem ) { categoryItem.disconnect( this ); items.push( categoryItem ); delete this.categories[ categoryItem.value ]; } } OO.ui.mixin.DraggableGroupElement.prototype.removeItems.call( this, items ); this.fitInput(); }; /** * Auto-fit the input. * * @method */ ve.ui.MWCategoryWidget.prototype.fitInput = function () { var availableSpace, inputWidth, $lastItem, $input = this.input.$element; if ( !this.items.length || !$input.is( ':visible' ) ) { return; } // Measure the input's natural size $input.css( 'width', '' ); inputWidth = $input.outerWidth( true ); // this.items hasn't been updated if this was triggered by a drag event, // so look at document order $lastItem = this.$group.find( '.ve-ui-mwCategoryItemWidget' ).last(); // Try to fit to the right of the last item availableSpace = Math.floor( this.$group.width() - ( $lastItem.position().left + $lastItem.outerWidth( true ) ) ); if ( availableSpace > inputWidth ) { $input.css( 'width', availableSpace ); } };