/* global $ */ /** * @class EditorOverlayBase * @private */ const Overlay = require( '../mobile.startup/Overlay' ), util = require( '../mobile.startup/util' ), parseBlockInfo = require( './parseBlockInfo' ), headers = require( '../mobile.startup/headers' ), icons = require( '../mobile.startup/icons' ), Button = require( '../mobile.startup/Button' ), IconButton = require( '../mobile.startup/IconButton' ), mfExtend = require( '../mobile.startup/mfExtend' ), blockMessageDrawer = require( './blockMessageDrawer' ), MessageBox = require( '../mobile.startup/MessageBox' ), mwUser = mw.user; /** * 'Edit' button * * @param {OO.ui.ToolGroup} toolGroup * @param {Object} config * @private */ function EditVeTool( toolGroup, config ) { config = config || {}; config.classes = [ 'visual-editor' ]; EditVeTool.super.call( this, toolGroup, config ); } OO.inheritClass( EditVeTool, OO.ui.Tool ); EditVeTool.static.name = 'editVe'; EditVeTool.static.icon = 'edit'; EditVeTool.static.group = 'editorSwitcher'; EditVeTool.static.title = mw.msg( 'mobile-frontend-editor-switch-visual-editor' ); /** * click handler * * @memberof EditVeTool * @instance */ EditVeTool.prototype.onSelect = function () { // will be overridden later }; /** * Toolbar update state handler. * * @memberof EditVeTool * @instance */ EditVeTool.prototype.onUpdateState = function () { // do nothing }; /** * Base class for SourceEditorOverlay and VisualEditorOverlay * * @class EditorOverlayBase * @private * @extends Overlay * @uses IconButton * @uses user * @param {Object} params Configuration options * @param {boolean} params.editSwitcher whether possible to switch mode in header * @param {boolean} params.hasToolbar whether the editor has a toolbar */ function EditorOverlayBase( params ) { const options = util.extend( true, { onBeforeExit: this.onBeforeExit.bind( this ), className: 'overlay editor-overlay', isBorderBox: false }, params, { events: util.extend( { 'click .back': 'onClickBack', 'click .continue': 'onClickContinue', 'click .submit': 'onClickSubmit', 'click .anonymous': 'onClickAnonymous' }, params.events ) } ); if ( options.isNewPage ) { options.placeholder = mw.msg( 'mobile-frontend-editor-placeholder-new-page', mwUser ); } // change the message to request a summary when not in article namespace if ( mw.config.get( 'wgNamespaceNumber' ) !== 0 ) { options.summaryRequestMsg = mw.msg( 'mobile-frontend-editor-summary' ); } this.isNewPage = options.isNewPage; this.sectionId = options.sectionId; this.overlayManager = options.overlayManager; Overlay.call( this, options ); } mfExtend( EditorOverlayBase, Overlay, { /** * @memberof EditorOverlayBase * @instance * @mixes Overlay#defaults * @property {Object} defaults Default options hash. * @property {OverlayManager} defaults.overlayManager instance * @property {mw.Api} defaults.api to interact with * @property {boolean} defaults.hasToolbar Whether the editor has a toolbar or not. When * disabled a header will be show instead. * @property {string} defaults.continueMsg Caption for the next button on edit form * which takes you to the screen that shows a preview and license information. * @property {string} defaults.closeMsg Caption for a button that takes you back to editing * from edit preview screen. * @property {string} defaults.summaryRequestMsg Header above edit summary input field * asking the user to summarize the changes they made to the page. * @property {string} defaults.summaryMsg A placeholder with examples for the summary input * field asking user what they changed. * @property {string} defaults.placeholder Placeholder text for empty sections. * @property {string} defaults.captchaMsg Placeholder for captcha input field. * @property {string} defaults.captchaTryAgainMsg A message shown when user enters * wrong CAPTCHA and a new one is displayed. * @property {string} defaults.switchMsg Label for button that allows the user * to switch between two different editing interfaces. * @property {string} defaults.licenseMsg Text and link of the license, * under which this contribution will be released to inform the user. */ defaults: util.extend( {}, Overlay.prototype.defaults, { hasToolbar: false, continueMsg: mw.msg( 'mobile-frontend-editor-continue' ), closeMsg: mw.msg( 'mobile-frontend-editor-keep-editing' ), summaryRequestMsg: mw.msg( 'mobile-frontend-editor-summary-request' ), summaryMsg: mw.msg( 'mobile-frontend-editor-summary-placeholder' ), placeholder: mw.msg( 'mobile-frontend-editor-placeholder' ), captchaMsg: mw.msg( 'mobile-frontend-account-create-captcha-placeholder' ), captchaTryAgainMsg: mw.msg( 'mobile-frontend-editor-captcha-try-again' ), switchMsg: mw.msg( 'mobile-frontend-editor-switch-editor' ), confirmMsg: mw.msg( 'mobile-frontend-editor-cancel-confirm' ), licenseMsg: undefined } ), /** * @inheritdoc * @memberof EditorOverlayBase * @instance */ template: util.template( `
{{>content}}
` ), /** * @memberof EditorOverlayBase * @instance */ sectionId: '', /** * Logs an event to http://meta.wikimedia.org/wiki/Schema:EditAttemptStep * * @memberof EditorOverlayBase * @instance * @param {Object} data */ log: function ( data ) { mw.track( 'editAttemptStep', util.extend( data, { // eslint-disable-next-line camelcase editor_interface: this.editor } ) ); }, /** * Logs an event to http://meta.wikimedia.org/wiki/Schema:VisualEditorFeatureUse * * @memberof EditorOverlayBase * @instance * @param {Object} data */ logFeatureUse: function ( data ) { mw.track( 'visualEditorFeatureUse', util.extend( data, { // eslint-disable-next-line camelcase editor_interface: this.editor } ) ); }, /** * If this is a new article, require confirmation before saving. * * @memberof EditorOverlayBase * @instance * @return {boolean} The user confirmed saving */ confirmSave: function () { if ( this.isNewPage && // TODO: Replace with an OOUI dialog // eslint-disable-next-line no-alert !window.confirm( mw.msg( 'mobile-frontend-editor-new-page-confirm', mwUser ) ) ) { return false; } else { return true; } }, /** * Executed when page save is complete. Updates urls and shows toast message. * * @memberof EditorOverlayBase * @instance * @param {number|null} newRevId ID of the newly created revision, or null if it was a * null edit. * @param {string} [redirectUrl] URL to redirect to, if different than the current URL. * @param {boolean} [tempUserCreated] Whether a temporary user was created */ onSaveComplete: function ( newRevId, redirectUrl, tempUserCreated ) { const self = this; this.saved = true; if ( newRevId ) { let action; if ( self.isNewPage ) { action = 'created'; } else if ( self.options.oldId ) { action = 'restored'; } else { action = 'saved'; } self.showSaveCompleteMsg( action, tempUserCreated ); } // Ensure we don't lose this event when logging this.log( { action: 'saveSuccess', // eslint-disable-next-line camelcase revision_id: newRevId } ); if ( tempUserCreated && redirectUrl ) { // The caller handles this redirect, either in SourceEditorOverlay or in VE's ArticleTarget return; } setTimeout( function () { // Wait for any other teardown navigation to happen (e.g. router.back()) // before setting our final location. if ( redirectUrl ) { // eslint-disable-next-line no-restricted-properties window.location.href = redirectUrl; } else if ( self.sectionId ) { // Ideally we'd want to do this via replaceState (see T189173) // eslint-disable-next-line no-restricted-properties window.location.hash = '#' + self.sectionId; } else { // Cancel the hash fragment // otherwise clicking back after a save will take you back to the editor. // We avoid calling the hide method of the overlay here as this can be asynchronous // and may conflict with the window.reload call below. // eslint-disable-next-line no-restricted-properties window.location.hash = '#'; } } ); }, /** * Show a save-complete message to the user * * @inheritdoc * @memberof VisualEditorOverlay * @instance * @param {string} action One of 'saved', 'created', 'restored' * @param {boolean} [tempUserCreated] Whether a temporary user was created */ showSaveCompleteMsg: function ( action, tempUserCreated ) { __non_webpack_require__( 'mediawiki.action.view.postEdit' ).fireHook( action, tempUserCreated ); }, /** * Executed when page save fails. Handles logging the error. Subclasses * should display error messages as appropriate. * * @memberof EditorOverlayBase * @instance * @param {Object} data API response */ onSaveFailure: function ( data ) { let code = data && data.errors && data.errors[0] && data.errors[0].code; const // Compare to ve.init.mw.ArticleTargetEvents.js in VisualEditor. typeMap = { badtoken: 'userBadToken', assertanonfailed: 'userNewUser', assertuserfailed: 'userNewUser', assertnameduserfailed: 'userNewUser', 'abusefilter-disallowed': 'extensionAbuseFilter', 'abusefilter-warning': 'extensionAbuseFilter', captcha: 'extensionCaptcha', // FIXME: This language is non-inclusive and we would love to change it, // but this relates to an error code provided by software. // This is blocked on T254649 spamblacklist: 'extensionSpamBlacklist', // FIXME: This language is non-inclusive and we would love to change it, // but this relates to an error code provided by software. // Removal of this line is blocked on T254650. 'titleblacklist-forbidden': 'extensionTitleBlacklist', pagedeleted: 'editPageDeleted', editconflict: 'editConflict' }; if ( data.edit && data.edit.captcha ) { code = 'captcha'; } this.log( { action: 'saveFailure', message: code, type: typeMap[code] || 'responseUnknown' } ); }, /** * Report load errors back to the user. Silently record the error using EventLogging. * * @memberof EditorOverlayBase * @instance * @param {string} text Text (HTML) of message to display to user */ reportError: function ( text ) { const errorNotice = new MessageBox( { type: 'error', msg: text, heading: mw.msg( 'mobile-frontend-editor-error' ) } ); this.$errorNoticeContainer.html( errorNotice.$el ); }, hideErrorNotice: function () { this.$errorNoticeContainer.empty(); }, /** * Prepares the penultimate screen before saving. * Expects to be overridden by child class. * * @memberof EditorOverlayBase * @instance */ onStageChanges: function () { this.showHidden( '.save-header, .save-panel' ); this.hideErrorNotice(); this.log( { action: 'saveIntent' } ); // Scroll to the top of the page, so that the summary input is visible // (even if overlay was scrolled down when editing) and weird iOS header // problems are avoided (header position not updating to the top of the // screen, instead staying lower until a subsequent scroll event). window.scrollTo( 0, 1 ); }, /** * Executed when the editor clicks the save button. Expects to be overridden by child * class. Checks if the save needs to be confirmed. * * @memberof EditorOverlayBase * @instance */ onSaveBegin: function () { this.confirmAborted = false; this.hideErrorNotice(); // Ask for confirmation in some cases if ( !this.confirmSave() ) { this.confirmAborted = true; return; } this.log( { action: 'saveAttempt' } ); }, /** * @inheritdoc * @memberof EditorOverlayBase */ preRender: function () { const options = this.options; this.options.headers = [ headers.formHeader( util.template( ` {{^hasToolbar}}

{{{editingMsg}}}

{{/hasToolbar}} {{#hasToolbar}}
{{/hasToolbar}} {{#editSwitcher}}
{{/editSwitcher}} ` ).render( { hasToolbar: options.hasToolbar, editSwitcher: options.editSwitcher, editingMsg: options.editingMsg } ), options.readOnly ? [] : [ new IconButton( { tagName: 'button', action: 'progressive', weight: 'primary', icon: 'next-invert', additionalClassNames: 'continue', disabled: true, title: options.continueMsg } ) ], icons.cancel(), 'initial-header' ), headers.saveHeader( options.previewingMsg, 'save-header hidden' ), headers.savingHeader( mw.msg( 'mobile-frontend-editor-wait' ) ) ]; }, /** * @inheritdoc * @memberof EditorOverlayBase * @instance */ postRender: function () { this.$errorNoticeContainer = this.$el.find( '#error-notice-container' ); Overlay.prototype.postRender.apply( this ); this.showHidden( '.initial-header' ); }, show: function () { const self = this; this.allowCloseWindow = mw.confirmCloseWindow( { // Returns true if content has changed test: function () { // Check if content has changed return self.hasChanged(); }, // Message to show the user, if content has changed message: mw.msg( 'mobile-frontend-editor-cancel-confirm' ), // Event namespace namespace: 'editwarning' } ); this.saved = false; Overlay.prototype.show.call( this ); // Inform other interested code that the editor has loaded /** * Internal for use in ContentTranslation and GrowthExperiments only. * * @event ~'mobileFrontend.editorOpened' * @memberof Hooks */ mw.hook( 'mobileFrontend.editorOpened' ).fire( this.editor ); }, /** * Back button click handler * * @memberof EditorOverlayBase * @instance */ onClickBack: function () {}, /** * Submit button click handler * * @memberof EditorOverlayBase * @instance */ onClickSubmit: function () { this.onSaveBegin(); }, /** * Continue button click handler * * @memberof EditorOverlayBase * @instance */ onClickContinue: function () { this.onStageChanges(); }, /** * "Edit without logging in" button click handler * * @memberof EditorOverlayBase * @instance */ onClickAnonymous: function () {}, /** * @memberof EditorOverlayBase * @instance * @param {Function} exit Callback to exit the overlay * @param {Function} cancel Callback to cancel exiting the overlay */ onBeforeExit: function ( exit, cancel ) { const self = this; if ( this.hasChanged() && !this.switching ) { if ( !this.windowManager ) { this.windowManager = OO.ui.getWindowManager(); this.windowManager.addWindows( [ new mw.widgets.AbandonEditDialog() ] ); } this.windowManager.openWindow( 'abandonedit' ) .closed.then( function ( data ) { if ( data && data.action === 'discard' ) { // log abandonment self.log( { action: 'abort', mechanism: 'cancel', type: 'abandon' } ); self.onExit(); exit(); } } ); cancel(); return; } if ( !this.switching && !this.saved ) { // log leaving without changes this.log( { action: 'abort', mechanism: 'cancel', // if this is VE, hasChanged will be false because the Surface has // already been destroyed (which is good because it stops us // double-showing the abandon changes dialog above)... but we can // test whether there *were* changes for logging purposes by // examining the target: type: ( this.target && this.target.edited ) ? 'abandon' : 'nochange' } ); } this.onExit(); exit(); if ( mw.config.get( 'wgAction' ) === 'edit' ) { // We got into the overlay via directly visiting an action=edit // URL, which has been taken over. As such, depending on // how we got here, the normal overlay process isn't going to // produce the correct result. setTimeout( function () { // This needs to happen after the overlay-hide has completed // so we have access to the "real" URL, and `exit` // unfortunately doesn't expose a promise for this. There's // several setTimeouts within the hide, so we're just going // to use a long-enough setTimeout of our own to skip those. if ( !mw.util.getParamValue( 'veaction' ) && !mw.util.getParamValue( 'action' ) ) { // Use reload if possible, to emulate having gone back location.reload(); } else { location.href = mw.util.getUrl(); } }, 100 ); } }, onExit: function () { // May not be set if overlay has not been previously shown if ( this.allowCloseWindow ) { this.allowCloseWindow.release(); } /** * Internal for use in ContentTranslation and GrowthExperiments only. * * @event ~'mobileFrontend.editorClosed' * @memberof Hooks */ mw.hook( 'mobileFrontend.editorClosed' ).fire(); }, /** * Sets additional values used for anonymous editing warning. * * @memberof EditorOverlayBase * @instance * @param {Object} options * @return {jQuery.Element} */ createAnonWarning: function ( options ) { const $actions = $( '
' ).addClass( 'actions' ), // Use MediaWiki ResourceLoader require(), not Webpack require() contLangMessages = ( __non_webpack_require__( './contLangMessages.json' ) ), msg = this.gateway.wouldautocreate ? 'mobile-frontend-editor-autocreatewarning' : 'mobile-frontend-editor-anonwarning', $anonWarning = $( '
' ).addClass( 'anonwarning content' ).append( new MessageBox( { type: 'notice', className: 'anon-msg', // eslint-disable-next-line mediawiki/msg-doc msg: mw.message( msg, contLangMessages[ 'tempuser-helppage' ] ).parse() } ).$el, $actions ), params = util.extend( { returnto: options.returnTo || ( // use wgPageName as this includes the namespace if outside Main mw.config.get( 'wgPageName' ) + '#/editor/' + ( options.sectionId || 'all' ) ), warning: 'mobile-frontend-edit-login-action' }, options.queryParams ), signupParams = util.extend( { type: 'signup', warning: 'mobile-frontend-edit-signup-action' }, options.signupQueryParams ), anonymousEditorActions = [ new Button( { label: mw.msg( 'mobile-frontend-editor-anon' ), block: true, additionalClassNames: 'anonymous progressive', progressive: true } ), new Button( { block: true, href: mw.util.getUrl( 'Special:UserLogin', params ), label: mw.msg( 'mobile-frontend-watchlist-cta-button-login' ) } ), new Button( { block: true, href: mw.util.getUrl( 'Special:UserLogin', util.extend( params, signupParams ) ), label: mw.msg( 'mobile-frontend-watchlist-cta-button-signup' ) } ) ]; $actions.append( anonymousEditorActions.map( function ( action ) { return action.$el; } ) ); return $anonWarning; }, /** * Creates and returns a copy of the anon talk message warning * * @memberof EditorOverlayBase * @instance * @return {jQuery.Element} */ createAnonTalkWarning: function () { return $( '.minerva-anon-talk-message' ).clone(); }, /** * Get an options object not containing any defaults or editor * specific options, so that it can be used to construct a * different editor for switching. * * @memberof EditorOverlayBase * @return {Object} Options */ getOptionsForSwitch: function () { // Only preserve options that would be passed in editor.js#setupEditor // and skip over defaults. return { switched: true, overlayManager: this.options.overlayManager, currentPageHTMLParser: this.options.currentPageHTMLParser, fakeScroll: this.options.fakeScroll, api: this.options.api, licenseMsg: this.options.licenseMsg, title: this.options.title, titleObj: this.options.titleObj, isAnon: this.options.isAnon, isNewPage: this.options.isNewPage, oldId: this.options.oldId, contentLang: this.options.contentLang, contentDir: this.options.contentDir, sectionId: this.options.sectionId }; }, /** * Checks whether the state of the thing being edited as changed. Expects to be * implemented by child class. * * @memberof EditorOverlayBase * @instance */ hasChanged: function () {}, /** * Get a promise that is resolved when the editor data has loaded, * or rejected when we're refusing to load the editor because the user is blocked. * * @memberof EditorOverlayBase * @instance * @return {jQuery.Promise} */ getLoadingPromise: function () { return this.dataPromise.then( function ( result ) { // check if user is blocked if ( result && result.blockinfo ) { const block = parseBlockInfo( result.blockinfo ), message = blockMessageDrawer( block ); return util.Deferred().reject( message ); } return result; } ); }, showEditNotices: function () { const overlay = this; if ( mw.config.get( 'wgMFEditNoticesFeatureConflict' ) ) { return; } this.getLoadingPromise().then( function ( data ) { if ( data.notices ) { const editNotices = Object.keys( data.notices ).filter( function ( key ) { if ( key.indexOf( 'editnotice' ) !== 0 ) { return false; } if ( key === 'editnotice-notext' ) { // This notice is shown on pages which don't have any // other edit notices. It's blank by default, but // some wikis have it template-generated and hidden // by CSS. It's filtered out from VE's API response, // but not from the source mode. return false; } // The contents of an edit notice is unlikely to change in the 24 hour // expiry window, so just record that a notice with this key has been shown. // If a cheap hashing function was available in core (or the API provided // as hash) it could be used here instead. const storageKey = 'mf-editnotices/' + mw.config.get( 'wgPageName' ) + '#' + key; if ( mw.storage.get( storageKey ) ) { return false; } mw.storage.set( storageKey, '1', 24 * 60 * 60 ); return true; } ); if ( editNotices.length ) { mw.loader.using( 'oojs-ui-windows' ).then( function () { const $container = $( '
' ).addClass( 'editor-overlay-editNotices' ); editNotices.forEach( function ( key ) { const $notice = $( '
' ).append( data.notices[ key ] ); $notice.addClass( 'editor-overlay-editNotice' ); $container.append( $notice ); } ); OO.ui.alert( $container ); overlay.logFeatureUse( { feature: 'notices', action: 'show' } ); } ); } } } ); }, /** * Handles a failed save due to a CAPTCHA provided by ConfirmEdit extension. * * @memberof EditorOverlayBase * @instance * @param {Object} details Details returned from the api. */ handleCaptcha: function ( details ) { const self = this, $input = this.$el.find( '.captcha-word' ); if ( this.captchaShown ) { $input.val( '' ); $input.attr( 'placeholder', this.options.captchaTryAgainMsg ); setTimeout( function () { $input.attr( 'placeholder', self.options.captchaMsg ); }, 2000 ); } // handle different mime types different if ( details.mime.indexOf( 'image/' ) === 0 ) { // image based CAPTCHA's like provided by FancyCaptcha, ReCaptcha or similar this.$el.find( '.captcha-panel#question' ).detach(); this.$el.find( '.captcha-panel img' ).attr( 'src', details.url ); } else { // not image based CAPTCHA. this.$el.find( '.captcha-panel #image' ).detach(); if ( details.mime.indexOf( 'text/html' ) === 0 ) { // handle mime type of HTML as HTML content (display as-is). // QuestyCaptcha now have default MIME type "text/html": see T147606 this.$el.find( '.captcha-panel #question' ).html( details.question ); } else { // handle mime types // (other than image based ones and HTML based ones) // as plain text by default. // e.g. MathCaptcha (solve a math formula) or // SimpleCaptcha (simple math formula) this.$el.find( '.captcha-panel #question' ).text( details.question ); } } this.showHidden( '.save-header, .captcha-panel' ); this.captchaShown = true; } } ); module.exports = EditorOverlayBase;