/*! * VisualEditor user interface MWTransclusionDialog class. * * @copyright See AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /** * Dialog for inserting and editing MediaWiki transclusions, i.e. a sequence of one or more template * invocations that strictly belong to each other (e.g. because they are unbalanced), possibly * mixed with raw wikitext snippets. * * Note the base class {@see ve.ui.MWTemplateDialog} alone does not allow to manage more than a * single template invocation. Most of the code for this feature set is exclusive to this subclass. * * @class * @extends ve.ui.MWTemplateDialog * * @constructor * @param {Object} [config] Configuration options */ ve.ui.MWTransclusionDialog = function VeUiMWTransclusionDialog( config ) { // Parent constructor ve.ui.MWTransclusionDialog.super.call( this, config ); // Properties this.isSidebarExpanded = null; this.hotkeyTriggers = {}; this.$element.on( 'keydown', this.onKeyDown.bind( this ) ); }; /* Inheritance */ OO.inheritClass( ve.ui.MWTransclusionDialog, ve.ui.MWTemplateDialog ); /* Static Properties */ ve.ui.MWTransclusionDialog.static.name = 'transclusion'; ve.ui.MWTransclusionDialog.static.size = 'larger'; ve.ui.MWTransclusionDialog.static.actions = [ ...ve.ui.MWTemplateDialog.static.actions, { action: 'mode', // label is set by updateModeActionState modes: [ 'edit', 'insert' ] }, { action: 'back', label: OO.ui.deferMsg( 'visualeditor-dialog-action-goback' ), modes: [ 'edit', 'insert' ], flags: [ 'safe', 'back' ] } ]; ve.ui.MWTransclusionDialog.static.smallScreenMaxWidth = 540; /* Static Methods */ /** * @return {boolean} */ ve.ui.MWTransclusionDialog.static.isSmallScreen = function () { return $( window ).width() <= ve.ui.MWTransclusionDialog.static.smallScreenMaxWidth; }; /* Methods */ /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.getEscapeAction = function () { const backOrClose = this.actions.get( { flags: [ 'back', 'close' ], visible: true } ); if ( backOrClose.length ) { return backOrClose[ 0 ].getAction(); } return null; }; /** * Handle outline controls move events. * * @private * @param {number} places Number of places to move the selected item */ ve.ui.MWTransclusionDialog.prototype.onOutlineControlsMove = function ( places ) { const part = this.transclusionModel.getPartFromId( this.bookletLayout.getSelectedTopLevelPartId() ); if ( !part ) { return; } const newPlace = this.transclusionModel.getParts().indexOf( part ) + places; if ( newPlace < 0 || newPlace >= this.transclusionModel.getParts().length ) { return; } // Move part to new location, and if dialog is loaded switch to new part page const promise = this.transclusionModel.addPart( part, newPlace ); if ( this.loaded && !this.preventReselection ) { // FIXME: Should be handled internally {@see ve.ui.MWTwoPaneTransclusionDialogLayout} promise.done( this.bookletLayout.focusPart.bind( this.bookletLayout, part.getId() ) ); } }; /** * Handle outline controls remove events. * * @private */ ve.ui.MWTransclusionDialog.prototype.onOutlineControlsRemove = function () { const controls = this.bookletLayout.getOutlineControls(); // T301914: Safe-guard for when a keyboard shortcut triggers this, instead of the actual button if ( !controls.isVisible() || !controls.removeButton.isVisible() || controls.removeButton.isDisabled() ) { return; } const partId = this.bookletLayout.getSelectedTopLevelPartId(), part = this.transclusionModel.getPartFromId( partId ); if ( part ) { this.transclusionModel.removePart( part ); } }; /** * Create a new template part at the end of the transclusion. * * @private */ ve.ui.MWTransclusionDialog.prototype.addTemplatePlaceholder = function () { this.addPart( new ve.dm.MWTemplatePlaceholderModel( this.transclusionModel ) ); }; /** * Handle add wikitext button click or hotkey events. * * @private */ ve.ui.MWTransclusionDialog.prototype.addWikitext = function () { this.addPart( new ve.dm.MWTransclusionContentModel( this.transclusionModel ) ); }; /** * Handle add parameter hotkey events. * * @private * @param {jQuery.Event} e Key down event */ ve.ui.MWTransclusionDialog.prototype.addParameter = function ( e ) { // Check if the focus was in e.g. a parameter list or filter input when the hotkey was pressed let partId = this.bookletLayout.sidebar.findPartIdContainingElement( e.target ), part = this.transclusionModel.getPartFromId( partId ); if ( !( part instanceof ve.dm.MWTemplateModel ) ) { // Otherwise add to the template that's currently selected via its title or parameter partId = this.bookletLayout.getTopLevelPartIdForSelection(); part = this.transclusionModel.getPartFromId( partId ); } if ( this.transclusionModel.isSingleTemplate() ) { part = this.transclusionModel.getParts()[ 0 ]; } if ( !( part instanceof ve.dm.MWTemplateModel ) ) { return; } // TODO: Use a distinct class for placeholder model rather than // these magical "empty" constants. const placeholderParameter = new ve.dm.MWParameterModel( part ); part.addParameter( placeholderParameter ); this.bookletLayout.focusPart( placeholderParameter.getId() ); this.autoExpandSidebar(); }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.onReplacePart = function ( removed, added ) { ve.ui.MWTransclusionDialog.super.prototype.onReplacePart.call( this, removed, added ); const parts = this.transclusionModel.getParts(); if ( parts.length === 0 ) { this.addPart( new ve.dm.MWTemplatePlaceholderModel( this.transclusionModel ) ); } else if ( parts.length > 1 ) { this.$element.removeClass( 've-ui-mwTransclusionDialog-single-transclusion' ); } // multipart message this.bookletLayout.stackLayout.$element.prepend( this.multipartMessage.$element ); this.multipartMessage.toggle( parts.length > 1 ); this.autoExpandSidebar(); this.updateModeActionState(); this.updateActionSet(); }; /** * @private */ ve.ui.MWTransclusionDialog.prototype.setupHotkeyTriggers = function () { // Lower-case modifier and key names as specified in {@see ve.ui.Trigger} const isMac = ve.getSystemPlatform() === 'mac', meta = isMac ? 'meta+' : 'ctrl+'; const hotkeys = { addTemplate: meta + 'd', addWikitext: meta + 'shift+y', addParameter: meta + 'shift+d', moveUp: meta + 'shift+up', moveDown: meta + 'shift+down', remove: meta + 'delete', removeBackspace: meta + 'backspace' }; const notInTextFields = /^(?!INPUT|TEXTAREA)/i; this.connectHotKeyBinding( hotkeys.addTemplate, this.addTemplatePlaceholder.bind( this ) ); this.connectHotKeyBinding( hotkeys.addWikitext, this.addWikitext.bind( this ) ); this.connectHotKeyBinding( hotkeys.addParameter, this.addParameter.bind( this ) ); this.connectHotKeyBinding( hotkeys.moveUp, this.onOutlineControlsMove.bind( this, -1 ), notInTextFields ); this.connectHotKeyBinding( hotkeys.moveDown, this.onOutlineControlsMove.bind( this, 1 ), notInTextFields ); this.connectHotKeyBinding( hotkeys.remove, this.onOutlineControlsRemove.bind( this ), notInTextFields ); if ( isMac ) { this.connectHotKeyBinding( hotkeys.removeBackspace, this.onOutlineControlsRemove.bind( this ), notInTextFields ); } const controls = this.bookletLayout.getOutlineControls(); this.addHotkeyToTitle( controls.addTemplateButton, hotkeys.addTemplate ); this.addHotkeyToTitle( controls.addWikitextButton, hotkeys.addWikitext ); this.addHotkeyToTitle( controls.upButton, hotkeys.moveUp ); this.addHotkeyToTitle( controls.downButton, hotkeys.moveDown ); this.addHotkeyToTitle( controls.removeButton, hotkeys.remove ); }; /** * @private * @param {string} hotkey * @param {Function} handler * @param {RegExp} [validTypes] */ ve.ui.MWTransclusionDialog.prototype.connectHotKeyBinding = function ( hotkey, handler, validTypes ) { this.hotkeyTriggers[ hotkey ] = { handler: handler, validTypes: validTypes }; }; /** * @private * @param {OO.ui.mixin.TitledElement} element * @param {string} hotkey */ ve.ui.MWTransclusionDialog.prototype.addHotkeyToTitle = function ( element, hotkey ) { // Separated with a space as in {@see OO.ui.Tool.updateTitle} element.setTitle( element.getTitle() + ' ' + new ve.ui.Trigger( hotkey ).getMessage() ); }; /** * Handles key down events. * * @protected * @param {jQuery.Event} e Key down event */ ve.ui.MWTransclusionDialog.prototype.onKeyDown = function ( e ) { const hotkey = new ve.ui.Trigger( e ).toString(), trigger = this.hotkeyTriggers[ hotkey ]; if ( trigger && ( !trigger.validTypes || trigger.validTypes.test( e.target.nodeName ) ) ) { trigger.handler( e ); e.preventDefault(); e.stopPropagation(); } }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.getPageFromPart = function ( part ) { const page = ve.ui.MWTransclusionDialog.super.prototype.getPageFromPart.call( this, part ); if ( !page && part instanceof ve.dm.MWTransclusionContentModel ) { return new ve.ui.MWTransclusionContentPage( part, part.getId(), { $overlay: this.$overlay, isReadOnly: this.isReadOnly() } ); } return page; }; /** * Automatically expand or collapse the sidebar according to default logic. * * @protected */ ve.ui.MWTransclusionDialog.prototype.autoExpandSidebar = function () { let expandSidebar; const isSmallScreen = this.constructor.static.isSmallScreen(); const showOtherActions = isSmallScreen || // Check for unknown actions, show the toolbar if any are available. this.actions.getOthers().some( ( action ) => action.action !== 'mode' ); this.actions.forEach( { actions: [ 'mode' ] }, ( action ) => { action.toggle( isSmallScreen ); } ); this.$otherActions.toggleClass( 'oo-ui-element-hidden', !showOtherActions ); if ( isSmallScreen && this.transclusionModel.isEmpty() ) { expandSidebar = false; } else if ( isSmallScreen && // eslint-disable-next-line no-jquery/no-class-state this.$content.hasClass( 've-ui-mwTransclusionDialog-small-screen' ) ) { // We did this already. If the sidebar is visible or not is now the user's decision. return; } else { expandSidebar = !isSmallScreen; } this.$content.toggleClass( 've-ui-mwTransclusionDialog-small-screen', isSmallScreen ); this.toggleSidebar( expandSidebar ); }; /** * Set if the sidebar is visible (which means the dialog is expanded), or collapsed. * * @param {boolean} expandSidebar * @private */ ve.ui.MWTransclusionDialog.prototype.toggleSidebar = function ( expandSidebar ) { if ( this.isSidebarExpanded === expandSidebar ) { return; } this.isSidebarExpanded = expandSidebar; this.$content .toggleClass( 've-ui-mwTransclusionDialog-collapsed', !expandSidebar ) .toggleClass( 've-ui-mwTransclusionDialog-expanded', expandSidebar ); this.bookletLayout.toggleOutline( expandSidebar ); this.updateTitle(); this.updateModeActionState(); // HACK blur any active input so that its dropdown will be hidden and won't end // up being mispositioned this.$content.find( 'input:focus' ).trigger( 'blur' ); if ( this.loaded && this.constructor.static.isSmallScreen() ) { // Updates the page sizes when the menu is toggled using the button. This needs // to happen after the animation when the panel is visible. setTimeout( () => { this.bookletLayout.stackLayout.getItems().forEach( ( page ) => { if ( page instanceof ve.ui.MWParameterPage ) { page.updateSize(); } } ); }, OO.ui.theme.getDialogTransitionDuration() ); // Reapply selection and scrolling when switching between panes. const selectedPage = this.bookletLayout.getCurrentPage(); if ( selectedPage ) { const name = selectedPage.getName(); // Align whichever panel is becoming visible, after animation completes. // TODO: Should hook onto an animation promise—but is this possible when pure CSS? setTimeout( () => { if ( expandSidebar ) { this.sidebar.setSelectionByPageName( name ); } else { selectedPage.scrollElementIntoView( { alignToTop: true, padding: { top: 20 } } ); if ( !OO.ui.isMobile() ) { selectedPage.focus(); } } }, OO.ui.theme.getDialogTransitionDuration() ); } } }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.updateTitle = function () { if ( !this.transclusionModel.isSingleTemplate() ) { this.title.setLabel( ve.msg( 'visualeditor-dialog-transclusion-title-edit-transclusion' ) ); } else { // Parent method ve.ui.MWTransclusionDialog.super.prototype.updateTitle.call( this ); } }; /** * Update the state of the 'mode' action * * @private */ ve.ui.MWTransclusionDialog.prototype.updateModeActionState = function () { const isExpanded = this.isSidebarExpanded, label = ve.msg( isExpanded ? 'visualeditor-dialog-transclusion-collapse-options' : 'visualeditor-dialog-transclusion-expand-options' ); this.actions.forEach( { actions: [ 'mode' ] }, ( action ) => { action.setLabel( label ); action.$button.attr( 'aria-expanded', isExpanded ? 1 : 0 ); } ); // The button is only visible on very narrow screens, {@see autoExpandSidebar}. // It's always needed, except in the initial placeholder state. const isInitialState = !isExpanded && this.transclusionModel.isEmpty(), canCollapse = !isInitialState; this.actions.setAbilities( { mode: canCollapse } ); }; /** * Add a part to the transclusion. * * @param {ve.dm.MWTransclusionPartModel} part Part to add */ ve.ui.MWTransclusionDialog.prototype.addPart = function ( part ) { const parts = this.transclusionModel.getParts(), partId = this.bookletLayout.getTopLevelPartIdForSelection(), selectedPart = this.transclusionModel.getPartFromId( partId ); // Insert after selected part, or at the end if nothing is selected const index = selectedPart ? parts.indexOf( selectedPart ) + 1 : parts.length; // Add the part, and if dialog is loaded switch to part page const promise = this.transclusionModel.addPart( part, index ); if ( this.loaded && !this.preventReselection ) { promise.done( this.bookletLayout.focusPart.bind( this.bookletLayout, part.getId() ) ); } }; /** * Show a confirm prompt before closing the dialog * * @param {string} prompt Prompt * @return {jQuery.Promise} Close promise */ ve.ui.MWTransclusionDialog.prototype.closeConfirm = function ( prompt ) { return OO.ui.confirm( prompt, { actions: [ { action: 'reject', label: ve.msg( 'visualeditor-dialog-transclusion-confirmation-reject' ), flags: 'safe' }, { action: 'accept', label: ve.msg( 'visualeditor-dialog-transclusion-confirmation-discard' ), // TODO: Destructive actions don't get focus by default, but maybe should here? flags: 'destructive' } ] } ); }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.getActionProcess = function ( action ) { const willLoseProgress = this.getMode() === 'insert' ? // A new template with no parameters is not considered valuable. this.transclusionModel.containsValuableData() : // The user has changed a parameter, and is not on the template search page. ( this.altered && !this.transclusionModel.isEmpty() ); switch ( action ) { case 'back': return new OO.ui.Process( () => { if ( willLoseProgress ) { this.closeConfirm( ve.msg( 'visualeditor-dialog-transclusion-back-confirmation-prompt' ) ).then( ( confirmed ) => { if ( confirmed ) { this.resetDialog(); } } ); } else { this.resetDialog(); } } ); case 'mode': return new OO.ui.Process( () => { this.toggleSidebar( !this.isSidebarExpanded ); } ); case '': // close action if ( willLoseProgress ) { return new OO.ui.Process( () => { this.closeConfirm( ve.msg( 'visualeditor-dialog-transclusion-close-confirmation-prompt' ) ).then( ( confirmed ) => { if ( confirmed ) { this.close(); } } ); } ); } } return ve.ui.MWTransclusionDialog.super.prototype.getActionProcess.call( this, action ); }; /** * Update the widgets in the dialog's action bar. * * @private */ ve.ui.MWTransclusionDialog.prototype.updateActionSet = function () { const backButton = this.actions.get( { flags: [ 'back' ] } ).pop(), saveButton = this.actions.get( { actions: [ 'done' ] } ).pop(); if ( saveButton && this.getMode() === 'edit' ) { saveButton.setLabel( ve.msg( 'visualeditor-dialog-transclusion-action-save' ) ); } const closeButton = this.actions.get( { flags: [ 'close' ] } ).pop(), canGoBack = this.getMode() === 'insert' && this.canGoBack && !this.transclusionModel.isEmpty(); closeButton.toggle( !canGoBack ); backButton.toggle( canGoBack ); }; /** * Revert the dialog back to its initial state. * * @private */ ve.ui.MWTransclusionDialog.prototype.resetDialog = function () { this.transclusionModel.reset(); this.bookletLayout.clearPages(); const placeholderPage = new ve.dm.MWTemplatePlaceholderModel( this.transclusionModel ); this.transclusionModel.addPart( placeholderPage ) .done( () => { this.bookletLayout.focusPart( placeholderPage.getId() ); this.autoExpandSidebar(); } ); }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.initialize = function () { // Parent method ve.ui.MWTransclusionDialog.super.prototype.initialize.call( this ); this.setupHotkeyTriggers(); // multipart message gets attached in onReplacePart() this.multipartMessage = new OO.ui.MessageWidget( { label: mw.message( 'visualeditor-dialog-transclusion-multipart-message' ).parseDom(), classes: [ 've-ui-mwTransclusionDialog-multipart-message' ] } ); ve.targetLinksToNewWindow( this.multipartMessage.$element[ 0 ] ); const helpPopup = new ve.ui.MWFloatingHelpElement( { label: mw.message( 'visualeditor-dialog-transclusion-help-title' ).text(), title: mw.message( 'visualeditor-dialog-transclusion-help-title' ).text(), $message: new OO.ui.FieldsetLayout( { items: [ new OO.ui.LabelWidget( { label: mw.message( 'visualeditor-dialog-transclusion-help-message' ).text() } ), this.getMessageButton( 'visualeditor-dialog-transclusion-help-page-help', 'helpNotice' ), this.getMessageButton( 'visualeditor-dialog-transclusion-help-page-shortcuts', 'keyboard' ) ], classes: [ 've-ui-mwTransclusionDialog-floatingHelpElement-fieldsetLayout' ] } ).$element } ); helpPopup.$element.addClass( 've-ui-mwTransclusionDialog-floatingHelpElement' ); helpPopup.$element.appendTo( this.$body ); // Events this.getManager().connect( this, { resize: ve.debounce( this.onWindowResize.bind( this ) ) } ); this.bookletLayout.getOutlineControls().connect( this, { addTemplate: 'addTemplatePlaceholder', addWikitext: 'addWikitext', move: 'onOutlineControlsMove', remove: 'onOutlineControlsRemove' } ); }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.getSetupProcess = function ( data ) { return ve.ui.MWTransclusionDialog.super.prototype.getSetupProcess.call( this, data ) .next( () => { this.bookletLayout.getOutlineControls().toggle( !this.transclusionModel.isSingleTemplate() ); this.$element.toggleClass( 've-ui-mwTransclusionDialog-single-transclusion', this.transclusionModel.isSingleTemplate() ); this.updateModeActionState(); this.autoExpandSidebar(); if ( !this.transclusionModel.isSingleTemplate() ) { this.sidebar.hideAllUnusedParameters(); } // We can do this only after the widget is visible on screen this.sidebar.initializeAllStickyHeaderHeights(); } ); }; /** * @private */ ve.ui.MWTransclusionDialog.prototype.onWindowResize = function () { if ( this.transclusionModel ) { this.autoExpandSidebar(); this.bookletLayout.getPagesOrdered().forEach( ( page ) => { if ( page instanceof ve.ui.MWParameterPage ) { page.updateSize(); } } ); } }; /** * @inheritdoc */ ve.ui.MWTransclusionDialog.prototype.getSizeProperties = function () { return ve.extendObject( { height: '90%' }, ve.ui.MWTransclusionDialog.super.prototype.getSizeProperties.call( this ) ); }; /** * Converts a message link into an OO.ui.ButtonWidget with an icon. * * @private * @param {string} message i18n message key * @param {string} icon icon name * @return {OO.ui.ButtonWidget} */ ve.ui.MWTemplateDialog.prototype.getMessageButton = function ( message, icon ) { // Messages that can be used here: // * visualeditor-dialog-transclusion-help-page-help // * visualeditor-dialog-transclusion-help-page-shortcuts const $link = mw.message( message ).parseDom(), button = new OO.ui.ButtonWidget( { label: $link.text(), href: $link.attr( 'href' ), target: '_blank', flags: 'progressive', icon: icon, framed: false } ); button.$button.attr( 'role', 'link' ); return button; }; /* Registration */ ve.ui.windowFactory.register( ve.ui.MWTransclusionDialog );