/*! * VisualEditor MediaWiki Initialization ViewPageTarget class. * * @copyright 2011-2015 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /*global confirm, alert */ /** * Initialization MediaWiki view page target. * * @class * @extends ve.init.mw.Target * * @constructor */ ve.init.mw.ViewPageTarget = function VeInitMwViewPageTarget() { // A workaround, as default URI does not get updated after pushState (bug 72334) var currentUri = new mw.Uri( location.href ); // Parent constructor ve.init.mw.Target.call( this, mw.config.get( 'wgRelevantPageName' ), currentUri.query.oldid ); // Parent constructor bound key event handlers, but we don't want them bound until // we activate; so unbind them again this.unbindHandlers(); this.onWatchToggleHandler = this.onWatchToggle.bind( this ); // Properties this.toolbarSaveButton = null; this.saveDialog = null; this.onBeforeUnloadFallback = null; this.onUnloadHandler = this.onUnload.bind( this ); this.active = false; this.activating = false; this.deactivating = false; this.edited = false; this.recreating = false; this.activatingDeferred = null; this.toolbarSetupDeferred = null; this.welcomeDialog = null; this.welcomeDialogPromise = null; // If this is true then #transformPage / #restorePage will not call pushState // This is to avoid adding a new history entry for the url we just got from onpopstate // (which would mess up with the expected order of Back/Forwards browsing) this.actFromPopState = false; this.popState = { tag: 'visualeditor' }; this.scrollTop = null; this.currentUri = currentUri; this.section = currentUri.query.vesection; this.initialEditSummary = currentUri.query.summary; this.namespaceName = mw.config.get( 'wgCanonicalNamespace' ); this.viewUri = new mw.Uri( mw.util.getUrl( this.pageName ) ); this.veEditUri = this.viewUri.clone().extend( { veaction: 'edit' } ); this.isViewPage = ( mw.config.get( 'wgAction' ) === 'view' && currentUri.query.diff === undefined ); this.originalDocumentTitle = document.title; this.tabLayout = mw.config.get( 'wgVisualEditorConfig' ).tabLayout; // Events this.connect( this, { save: 'onSave', saveErrorEmpty: 'onSaveErrorEmpty', saveErrorSpamBlacklist: 'onSaveErrorSpamBlacklist', saveErrorAbuseFilter: 'onSaveErrorAbuseFilter', saveErrorNewUser: 'onSaveErrorNewUser', saveErrorCaptcha: 'onSaveErrorCaptcha', saveErrorUnknown: 'onSaveErrorUnknown', saveErrorPageDeleted: 'onSaveErrorPageDeleted', saveErrorTitleBlacklist: 'onSaveErrorTitleBlacklist', loadError: 'onLoadError', surfaceReady: 'onSurfaceReady', editConflict: 'onEditConflict', showChanges: 'onShowChanges', showChangesError: 'onShowChangesError', noChanges: 'onNoChanges', serializeError: 'onSerializeError' } ); if ( history.replaceState ) { // We replace the current state with one that's marked with our tag. This way, when users // use the Back button to exit the editor we can restore Read mode. This is because we want // to ignore foreign states in onWindowPopState. Without this, the Read state is foreign. // FIXME: There should be a much better solution than this. history.replaceState( this.popState, document.title, currentUri ); } this.setupSkinTabs(); window.addEventListener( 'popstate', this.onWindowPopState.bind( this ) ); }; /* Inheritance */ OO.inheritClass( ve.init.mw.ViewPageTarget, ve.init.mw.Target ); /* Events */ /** * @event deactivate */ /* Static Properties */ /** * Compatibility map used with jQuery.client to black-list incompatible browsers. * * @static * @property */ ve.init.mw.ViewPageTarget.compatibility = { // The key is the browser name returned by jQuery.client // The value is either null (match all versions) or a list of tuples // containing an inequality (<,>,<=,>=) and a version number whitelist: { firefox: [['>=', 15]], iceweasel: [['>=', 10]], safari: [['>=', 7]], chrome: [['>=', 19]], opera: [['>=', 15]] } }; /* Events */ /** * @event saveWorkflowBegin * Fired when user clicks the button to open the save dialog. */ /** * @event saveWorkflowEnd * Fired when user exits the save workflow */ /** * @event saveReview * Fired when user initiates review changes in save workflow */ /** * @event saveInitiated * Fired when user initiates saving of the document */ /* Methods */ /** * Verify that a PopStateEvent correlates to a state we created. * * @param {Mixed} popState From PopStateEvent#state * @return {boolean} */ ve.init.mw.ViewPageTarget.prototype.verifyPopState = function ( popState ) { return popState && popState.tag === 'visualeditor'; }; /** * @inheritdoc */ ve.init.mw.ViewPageTarget.prototype.setupToolbar = function ( surface ) { var toolbar, wasSetup = !!this.toolbar, target = this; ve.track( 'trace.setupToolbar.enter' ); // Parent method ve.init.mw.Target.prototype.setupToolbar.call( this, surface ); toolbar = this.getToolbar(); ve.track( 'trace.setupToolbar.exit' ); if ( !wasSetup ) { setTimeout( function () { var height = toolbar.$bar.outerHeight(); toolbar.$element.css( 'height', height ); toolbar.$element.one( 'transitionend', function () { // Clear to allow growth during use and when resizing window toolbar.$element.css( 'height', '' ); target.toolbarSetupDeferred.resolve(); } ); } ); this.toolbarSetupDeferred.done( function () { var surface = target.getSurface(); // Check the surface wasn't torn down while the toolbar was animating if ( surface ) { ve.track( 'trace.initializeToolbar.enter' ); target.getToolbar().initialize(); surface.getView().emit( 'position' ); surface.getContext().updateDimensions(); ve.track( 'trace.initializeToolbar.exit' ); ve.track( 'trace.activate.exit' ); } } ); } }; /** * @inheritdoc */ ve.init.mw.ViewPageTarget.prototype.attachToolbar = function () { // Move the toolbar to top of target, before heading etc. // Avoid re-attaching as it breaks CSS animations if ( !this.toolbar.$element.parent().is( this.$element ) ) { this.toolbar.$element // Set 0 before attach (expanded in #setupToolbar) .css( 'height', '0' ) .addClass( 've-init-mw-viewPageTarget-toolbar' ); this.$element.prepend( this.toolbar.$element ); } }; /** * Set up notices for things like unknown browsers. * Needs to be done on each activation because localNoticeMessages is cleared in clearState. */ ve.init.mw.ViewPageTarget.prototype.setupLocalNoticeMessages = function () { if ( mw.config.get( 'wgTranslatePageTranslation' ) === 'source' ) { // Warn users if they're on a source of the Page Translation feature this.localNoticeMessages.push( 'visualeditor-pagetranslationwarning' ); } if ( !( 'vewhitelist' in this.currentUri.query || $.client.test( ve.init.mw.ViewPageTarget.compatibility.whitelist, null, true ) ) ) { // Show warning in unknown browsers that pass the support test // Continue at own risk. this.localNoticeMessages.push( 'visualeditor-browserwarning' ); } }; /** * Handle the watch button being toggled on/off. * @param {jQuery.Event} e Event object whih triggered the event * @param {string} actionPerformed 'watch' or 'unwatch' */ ve.init.mw.ViewPageTarget.prototype.onWatchToggle = function ( e, actionPerformed ) { if ( !this.active && !this.activating ) { return; } this.$checkboxes.filter( '#wpWatchthis' ) .prop( 'checked', mw.user.options.get( 'watchdefault' ) || ( mw.user.options.get( 'watchcreations' ) && !this.pageExists ) || actionPerformed === 'watch' ); }; /** * @inheritdoc */ ve.init.mw.ViewPageTarget.prototype.bindHandlers = function () { ve.init.mw.ViewPageTarget.super.prototype.bindHandlers.call( this ); if ( this.onWatchToggleHandler ) { $( '#ca-watch, #ca-unwatch' ).on( 'watchpage.mw', this.onWatchToggleHandler ); } }; /** * @inheritdoc */ ve.init.mw.ViewPageTarget.prototype.unbindHandlers = function () { ve.init.mw.ViewPageTarget.super.prototype.unbindHandlers.call( this ); if ( this.onWatchToggleHandler ) { $( '#ca-watch, #ca-unwatch' ).off( 'watchpage.mw', this.onWatchToggleHandler ); } }; /** * Switch to edit mode. * * @param {jQuery.Promise} [dataPromise] Promise for pending request from * mw.libs.ve.targetLoader#requestPageData, if any * @return {jQuery.Promise} */ ve.init.mw.ViewPageTarget.prototype.activate = function ( dataPromise ) { var surface, pageTarget = this; if ( !this.active && !this.activating ) { this.activating = true; this.activatingDeferred = $.Deferred(); this.toolbarSetupDeferred = $.Deferred(); this.maybeShowWelcomeDialog(); $( 'html' ).removeClass( 've-loading' ).addClass( 've-activating' ); $.when( this.activatingDeferred, this.toolbarSetupDeferred ).done( function () { $( 'html' ).removeClass( 've-activating' ).addClass( 've-active' ); // We have to focus the page after hiding the original content, otherwise // in firefox the contentEditable container was below the view page, and // 'focus' scrolled the screen down. pageTarget.getSurface().getView().focus(); } ).fail( function () { $( 'html' ).removeClass( 've-activating' ); } ); this.bindHandlers(); this.originalEditondbclick = mw.user.options.get( 'editondblclick' ); mw.user.options.set( 'editondblclick', 0 ); // User interface changes this.transformPage(); this.setupLocalNoticeMessages(); this.saveScrollPosition(); // Create dummy surface to show toolbar while loading surface = this.addSurface( [] ); surface.disable(); // setSurface creates dummy toolbar this.setSurface( surface ); // Disconnect the tool factory listeners so the toolbar // doesn't start showing new tools as they load, too // much flickering this.getToolbar().getToolFactory().off( 'register' ); // Disable all the tools this.getToolbar().updateToolState(); this.load( dataPromise ); } return this.activatingDeferred.promise(); }; /** * Determines whether we want to switch to view mode or not (displaying a dialog if necessary) * Then, if we do, actually switches to view mode. * * A dialog will not be shown if deactivate() is called while activation is still in progress, * or if the noDialog parameter is set to true. If deactivate() is called while the target * is deactivating, or while it's not active and not activating, nothing happens. * * @param {boolean} [noDialog] Do not display a dialog * @param {string} [trackMechanism] Abort mechanism; used for event tracking if present */ ve.init.mw.ViewPageTarget.prototype.deactivate = function ( noDialog, trackMechanism ) { var target = this; if ( this.deactivating || ( !this.active && !this.activating ) ) { return; } // Just in case this wasn't closed before if ( this.welcomeDialog ) { this.welcomeDialog.close(); } if ( noDialog || this.activating || !this.edited ) { this.cancel( trackMechanism ); } else { this.getSurface().dialogs.openWindow( 'cancelconfirm' ).then( function ( opened ) { opened.then( function ( closing ) { closing.then( function ( data ) { if ( data && data.action === 'discard' ) { target.cancel( trackMechanism ); } } ); } ); } ); } }; /** * Switch to view mode * * @param {string} [trackMechanism] Abort mechanism; used for event tracking if present */ ve.init.mw.ViewPageTarget.prototype.cancel = function ( trackMechanism ) { var abortType, target = this, promises = []; // Event tracking if ( trackMechanism ) { if ( this.activating ) { abortType = 'preinit'; } else if ( !this.edited ) { abortType = 'nochange'; } else if ( this.saving ) { abortType = 'abandonMidsave'; } else { // switchwith and switchwithout do not go through this code path, // they go through switchToWikitextEditor() instead abortType = 'abandon'; } ve.track( 'mwedit.abort', { type: abortType, mechanism: trackMechanism } ); } this.deactivating = true; $( 'html' ).addClass( 've-deactivating' ).removeClass( 've-activated ve-active' ); // User interface changes if ( this.elementsThatHadOurAccessKey ) { this.elementsThatHadOurAccessKey.attr( 'accesskey', ve.msg( 'accesskey-save' ) ); } this.restorePage(); this.unbindHandlers(); mw.user.options.set( 'editondblclick', this.originalEditondbclick ); this.originalEditondbclick = undefined; if ( this.toolbarSaveButton ) { // If deactivate is called before a successful load, then the save button has not yet been // fully set up so disconnecting it would throw an error when trying call methods on the // button property (bug 46456) this.toolbarSaveButton.disconnect( this ); this.toolbarSaveButton.$element.detach(); this.getToolbar().$actions.empty(); } // Check we got as far as setting up the surface if ( this.active ) { this.tearDownUnloadHandlers(); // If we got as far as setting up the surface, tear that down promises.push( this.tearDownSurface() ); } else if ( this.toolbar ) { // If a dummy toolbar was created, destroy it this.toolbar.destroy(); } $.when.apply( null, promises ).done( function () { // If there is a load in progress, abort it if ( target.loading ) { target.loading.abort(); } target.clearState(); target.docToSave = null; target.initialEditSummary = new mw.Uri().query.summary; target.deactivating = false; target.activating = false; target.activatingDeferred.reject(); $( 'html' ).removeClass( 've-deactivating' ); // Move remaining elements back out of the target target.$element.parent().append( target.$element.children() ); mw.hook( 've.deactivationComplete' ).fire( target.edited ); } ); }; /** * Handle failed DOM load event. * * @method * @param {string} errorTypeText * @param {string} error */ ve.init.mw.ViewPageTarget.prototype.onLoadError = function ( errorText, error ) { // Don't show an error if the load was manually aborted // The response.status check here is to catch aborts triggered by navigation away from the page if ( error && Object.prototype.hasOwnProperty.call( error, 'error' ) && Object.prototype.hasOwnProperty.call( error.error, 'info' ) ) { error = error.error.info; } if ( errorText === 'http' && ( error.statusText !== 'abort' || error.xhr.status !== 504 ) && confirm( ve.msg( 'visualeditor-loadwarning', 'HTTP ' + error.xhr.status ) ) ) { this.load(); } else if ( errorText === 'http' && error.xhr.status === 504 && confirm( ve.msg( 'visualeditor-timeout' ) ) ) { if ( 'veaction' in this.currentUri.query ) { delete this.currentUri.query.veaction; } this.currentUri.query.action = 'edit'; location.href = this.currentUri.toString(); } else if ( errorText !== 'http' && typeof error === 'string' && confirm( ve.msg( 'visualeditor-loadwarning', errorText + ': ' + error ) ) ) { this.load(); } else { // Something weird happened? Deactivate // Not passing trackMechanism because we don't know what happened // and this is not a user action this.deactivate( true ); } }; /** * Once surface is ready ready, init UI * * @method */ ve.init.mw.ViewPageTarget.prototype.onSurfaceReady = function () { var surfaceReadyTime = ve.now(), target = this; if ( !this.activating ) { // Activation was aborted before we got here. Do nothing // TODO are there things we need to clean up? return; } this.activating = false; this.getSurface().getModel().connect( this, { history: 'updateToolbarSaveButtonState' } ); // TODO: mwTocWidget should probably live in a ve.ui.MWSurface subclass if ( mw.config.get( 'wgVisualEditorConfig' ).enableTocWidget ) { this.getSurface().mwTocWidget = new ve.ui.MWTocWidget( this.getSurface() ); } // Track how long it takes for the first transaction to happen this.surface.getModel().getDocument().once( 'transact', function () { ve.track( 'mwtiming.behavior.firstTransaction', { duration: ve.now() - surfaceReadyTime, targetName: target.constructor.static.name } ); } ); // Update UI this.changeDocumentTitle(); this.setupToolbarSaveButton(); this.attachToolbarSaveButton(); this.restoreScrollPosition(); this.restoreEditSection(); this.setupUnloadHandlers(); this.maybeShowMetaDialog(); this.activatingDeferred.resolve(); this.events.trackActivationComplete(); mw.hook( 've.activationComplete' ).fire(); }; /** * Handle Escape key presses. * @param {jQuery.Event} e Keydown event */ ve.init.mw.ViewPageTarget.prototype.onDocumentKeyDown = function ( e ) { // Parent method ve.init.mw.ViewPageTarget.super.prototype.onDocumentKeyDown.apply( this, arguments ); var target = this; if ( e.which === OO.ui.Keys.ESCAPE ) { setTimeout( function () { // Listeners should stopPropagation if they handle the escape key, but // also check they didn't fire after this event, as would be the case if // they were bound to the document. if ( !e.isPropagationStopped() ) { target.deactivate( false, 'navigate-read' ); } } ); e.preventDefault(); } }; /** * Handle clicks on the view tab. * * @method * @param {jQuery.Event} e Mouse click event */ ve.init.mw.ViewPageTarget.prototype.onViewTabClick = function ( e ) { if ( ( e.which && e.which !== 1 ) || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey ) { return; } this.deactivate( false, 'navigate-read' ); e.preventDefault(); }; /** * Handle successful DOM save event. * * @method * @param {string} html Rendered page HTML from server * @param {string} categoriesHtml Rendered categories HTML from server * @param {number} newid New revision id, undefined if unchanged * @param {boolean} isRedirect Whether this page is a redirect or not * @param {string} displayTitle What HTML to show as the page title * @param {Object} lastModified Object containing user-formatted date and time strings, or undefined if we made no change. */ ve.init.mw.ViewPageTarget.prototype.onSave = function ( html, categoriesHtml, newid, isRedirect, displayTitle, lastModified, contentSub ) { var newUrlParams, watchChecked; this.saveDeferred.resolve(); if ( !this.pageExists || this.restoring ) { // This is a page creation or restoration, refresh the page this.tearDownUnloadHandlers(); newUrlParams = newid === undefined ? {} : { venotify: this.restoring ? 'restored' : 'created' }; if ( isRedirect ) { newUrlParams.redirect = 'no'; } location.href = this.viewUri.extend( newUrlParams ); } else { // Update watch link to match 'watch checkbox' in save dialog. // User logged in if module loaded. // Just checking for mw.page.watch is not enough because in Firefox // there is Object.prototype.watch... if ( mw.page.watch && mw.page.watch.updateWatchLink ) { watchChecked = this.saveDialog.$saveOptions .find( '.ve-ui-mwSaveDialog-checkboxes' ) .find( '#wpWatchthis' ) .prop( 'checked' ); mw.page.watch.updateWatchLink( $( '#ca-watch a, #ca-unwatch a' ), watchChecked ? 'unwatch' : 'watch' ); } // If we were explicitly editing an older version, make sure we won't // load the same old version again, now that we've saved the next edit // will be against the latest version. // TODO: What about oldid in the url? this.restoring = false; if ( newid !== undefined ) { mw.config.set( { wgCurRevisionId: newid, wgRevisionId: newid } ); this.revid = newid; } this.saveDialog.reset(); this.replacePageContent( html, categoriesHtml, displayTitle, lastModified, contentSub ); if ( newid !== undefined ) { $( '#t-permalink a, #coll-download-as-rl a' ).each( function () { var uri = new mw.Uri( $( this ).attr( 'href' ) ); uri.query.oldid = newid; $( this ).attr( 'href', uri.toString() ); } ); } this.setupSectionEditLinks(); // Tear down the target now that we're done saving // Not passing trackMechanism because this isn't an abort action this.deactivate( true ); if ( newid !== undefined ) { mw.hook( 'postEdit' ).fire( { message: ve.msg( 'postedit-confirmation-saved', mw.user ) } ); } } }; /** * @inheritdoc */ ve.init.mw.ViewPageTarget.prototype.onSaveError = function () { this.pageDeletedWarning = false; ve.init.mw.ViewPageTarget.super.prototype.onSaveError.apply( this, arguments ); }; /** * Update save dialog message on general error * * @method */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorEmpty = function () { this.showSaveError( ve.msg( 'visualeditor-saveerror', 'Empty server response' ), false /* prevents reapply */ ); }; /** * Update save dialog message on spam blacklist error * * @method * @param {Object} editApi */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorSpamBlacklist = function ( editApi ) { this.showSaveError( ve.msg( 'spamprotectiontext' ) + ' ' + ve.msg( 'spamprotectionmatch', mw.language.listToText( editApi.spamblacklist.split( '|' ) ) ), false // prevents reapply ); }; /** * Update save dialog message on abuse filter error * * @method * @param {Object} editApi */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorAbuseFilter = function ( editApi ) { this.showSaveError( $( $.parseHTML( editApi.warning ) ) ); // Don't disable the save button. If the action is not disallowed the user may save the // edit by pressing Save again. The AbuseFilter API currently has no way to distinguish // between filter triggers that are and aren't disallowing the action. }; /** * Update save dialog message on title blacklist error * * @method */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorTitleBlacklist = function () { this.showSaveError( mw.msg( 'visualeditor-saveerror-titleblacklist' ) ); }; /** * Update save dialog when token fetch indicates another user is logged in * * @method * @param {string|null} username Name of newly logged-in user, or null if anonymous */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorNewUser = function ( username ) { var badToken, userMsg; badToken = document.createTextNode( mw.msg( 'visualeditor-savedialog-error-badtoken' ) + ' ' ); // mediawiki.jqueryMsg has a bug with [[User:$1|$1]] (bug 51388) if ( username === null ) { userMsg = 'visualeditor-savedialog-identify-anon'; } else { userMsg = 'visualeditor-savedialog-identify-user---' + username; } this.showSaveError( $( badToken ).add( $.parseHTML( mw.message( userMsg ).parse() ) ) ); }; /** * Update save dialog on captcha error * * @method * @param {Object} editApi */ ve.init.mw.ViewPageTarget.prototype.onSaveErrorCaptcha = function ( editApi ) { var $captchaDiv = $( '