/*! * VisualEditor MediaWiki Initialization ViewPageTarget class. * * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt * @license The MIT License (MIT); see LICENSE.txt */ /*global mw, confirm, alert */ /** * Initialization MediaWiki view page target. * * @class * @extends ve.init.mw.Target * * @constructor */ ve.init.mw.ViewPageTarget = function VeInitMwViewPageTarget() { var browserWhitelisted, currentUri = new mw.Uri(); // Parent constructor ve.init.mw.Target.call( this, $( '#content' ), mw.config.get( 'wgRelevantPageName' ), currentUri.query.oldid ); // Properties this.$document = null; this.$spinner = $( '
' ); this.$toolbarTracker = $( '
' ); this.toolbarTrackerFloating = null; this.toolbarOffset = null; this.toolbarCancelButton = null; this.toolbarSaveButton = null; this.saveDialog = null; this.toolbarEditNoticesButton = null; this.toolbarEditNotices = null; this.toolbarBetaNoticesButton = null; this.toolbarBetaNotices = null; this.toolbarHelpButton = null; this.toolbarFeedbackButton = null; this.toolbarMetaButton = null; this.$saveDialog = $( '
' ) .addClass( 've-init-mw-viewPageTarget-saveDialog' ); this.onBeforeUnloadFallback = null; this.onBeforeUnloadHandler = null; this.active = false; this.edited = false; this.sanityCheckFinished = false; this.sanityCheckVerified = false; this.activating = false; this.deactivating = false; // 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.scrollTop = null; this.currentUri = currentUri; this.section = currentUri.query.vesection || null; this.sectionPositionRestored = false; this.sectionTitleRestored = false; this.namespaceName = mw.config.get( 'wgCanonicalNamespace' ); this.viewUri = new mw.Uri( mw.util.wikiGetlink( 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.editSummaryByteLimit = 255; this.tabLayout = mw.config.get( 'wgVisualEditorConfig' ).tabLayout; /** * @property {jQuery.Promise|null} */ this.sanityCheckPromise = null; browserWhitelisted = ( 'vewhitelist' in currentUri.query || $.client.test( ve.init.mw.ViewPageTarget.compatibility.whitelist, null, true ) ); // Events this.connect( this, { 'load': 'onLoad', 'save': 'onSave', 'loadError': 'onLoadError', 'tokenError': 'onTokenError', 'saveError': 'onSaveError', 'editConflict': 'onEditConflict', 'showChanges': 'onShowChanges', 'showChangesError': 'onShowChangesError', 'noChanges': 'onNoChanges', 'serializeError': 'onSerializeError' } ); if ( !browserWhitelisted ) { // Show warning in unknown browsers that pass the support test // Continue at own risk. this.localNoticeMessages.push( 'visualeditor-browserwarning' ); } if ( currentUri.query.venotify ) { // The following messages can be used here: // visualeditor-notification-saved // visualeditor-notification-created // visualeditor-notification-restored mw.hook( 'postEdit' ).fire( { 'message': ve.msg( 'visualeditor-notification-' + currentUri.query.venotify, new mw.Title( this.pageName ).toText() ) } ); if ( window.history.replaceState ) { delete currentUri.query.venotify; window.history.replaceState( null, document.title, currentUri ); } } this.setupSkinTabs(); window.addEventListener( 'popstate', ve.bind( this.onWindowPopState, this ) ) ; }; /* Inheritance */ ve.inheritClass( ve.init.mw.ViewPageTarget, ve.init.mw.Target ); /* 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': [['>=', 5]], 'chrome': [['>=', 19]] } }; /* Methods */ /** * Switch to edit mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.activate = function () { if ( !this.active && !this.activating ) { this.activating = true; // User interface changes this.transformPage(); this.showSpinner(); this.hideTableOfContents(); this.mutePageContent(); this.mutePageTitle(); this.saveScrollPosition(); this.load(); } }; /** * Switch to view mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.deactivate = function ( override ) { if ( override || ( this.active && !this.deactivating ) ) { if ( override || !this.edited || confirm( ve.msg( 'visualeditor-viewpage-savewarning' ) ) ) { this.deactivating = true; // User interface changes this.restorePage(); this.hideSpinner(); this.showTableOfContents(); if ( this.toolbarCancelButton ) { // If deactivate is called before a successful load, then // setupToolbarButtons has not been called yet and as such tearDownToolbarButtons // would throw an error when trying call methods on the button property (bug 46456) this.tearDownToolbarButtons(); this.detachToolbarButtons(); } this.saveDialog.reset(); this.saveDialog.close(); // Check we got as far as setting up the surface if ( this.active ) { // If we got as far as setting up the surface, tear that down this.tearDownSurface(); } // Show/restore components that are otherwise handled by tearDownSurface this.showPageContent(); this.restorePageTitle(); // If there is a load in progress, abort it if ( this.loading ) { this.loading.abort(); } this.deactivating = false; mw.hook( 've.deactivationComplete' ).fire(); } } }; /** * Handle successful DOM load event. * * @method * @param {HTMLDocument} doc Parsed DOM from server */ ve.init.mw.ViewPageTarget.prototype.onLoad = function ( doc ) { if ( this.activating ) { this.edited = false; this.doc = doc; this.setUpSurface( doc, ve.bind( function() { this.startSanityCheck(); this.setupToolbarButtons(); this.setupToolbarEditNotices(); this.setupToolbarBetaNotices(); this.setupSaveDialog(); this.attachToolbarButtons(); this.restoreScrollPosition(); this.restoreEditSection(); this.setupBeforeUnloadHandler(); this.$document[0].focus(); this.activating = false; if ( mw.config.get( 'wgVisualEditorConfig' ).showBetaWelcome ) { this.showBetaWelcome(); } mw.hook( 've.activationComplete' ).fire(); }, this ) ); } }; /** * Handle failed DOM load event. * * @method * @param {Object} response HTTP Response object * @param {string} status Text status message * @param {Mixed} error Thrown exception or HTTP error string */ ve.init.mw.ViewPageTarget.prototype.onLoadError = function ( response, status ) { // Don't show an error if the load was manually aborted if ( status !== 'abort' && confirm( ve.msg( 'visualeditor-loadwarning', status ) ) ) { this.load(); } else { this.activating = false; // User interface changes this.deactivate( true ); } }; /** * Handle failed token refresh event. * * @method * @param {Object} response Response object * @param {string} status Text status message * @param {Mixed} error Thrown exception or HTTP error string */ ve.init.mw.ViewPageTarget.prototype.onTokenError = function ( response, status ) { if ( confirm( ve.msg( 'visualeditor-loadwarning-token', status ) ) ) { this.load(); } else { this.activating = false; // User interface changes this.deactivate( true ); } }; /** * Handle successful DOM save event. * * @method * @param {HTMLElement} html Rendered HTML from server * @param {number} [newid] New revision id, undefined if unchanged */ ve.init.mw.ViewPageTarget.prototype.onSave = function ( html, newid ) { if ( !this.pageExists || this.restoring ) { // This is a page creation or restoration, refresh the page this.tearDownBeforeUnloadHandler(); window.location.href = this.viewUri.extend( { 'venotify': this.restoring ? 'restored' : 'created' } ); } 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 ) { var watchChecked = this.$saveDialog .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 ); this.revid = newid; } this.saveDialog.close(); this.saveDialog.reset(); this.replacePageContent( html ); this.setupSectionEditLinks(); this.tearDownBeforeUnloadHandler(); this.deactivate( true ); mw.hook( 'postEdit' ).fire( { 'message': ve.msg( 'visualeditor-notification-saved', new mw.Title( this.pageName ).toText() ) } ); } }; /** * Handle failed DOM save event. * * @method * @param {Object} jqXHR * @param {string} status Text status message * @param {Object|null} data API response data */ ve.init.mw.ViewPageTarget.prototype.onSaveError = function ( jqXHR, status, data ) { var api, editApi, viewPage = this; this.saveDialog.saveButton.setDisabled( false ); this.saveDialog.$loadingIcon.hide(); this.saveDialog.clearMessage( 'api-save-error' ); // Handle empty response if ( !data ) { this.saveDialog.showMessage( 'api-save-error', ve.msg( 'visualeditor-saveerror', 'Empty server response' ), { wrap: 'error' } ); this.saveDialog.saveButton.setDisabled( true ); return; } editApi = data && data.visualeditoredit && data.visualeditoredit.edit; // Handle spam blacklist error (either from core or from Extension:SpamBlacklist) if ( editApi && editApi.spamblacklist ) { this.saveDialog.showMessage( 'api-save-error', // TODO: Use mediawiki.language equivalant of Language.php::listToText once it exists ve.msg( 'spamprotectiontext' ) + ' ' + ve.msg( 'spamprotectionmatch', editApi.spamblacklist.split( '|' ).join( ', ' ) ), { wrap: 'error' } ); this.saveDialog.saveButton.setDisabled( true ); return; } // Handle warnings/errors from Extension:AbuseFilter // TODO: Move this to a plugin if ( editApi && editApi.info && editApi.info.indexOf( 'Hit AbuseFilter:' ) === 0 && editApi.warning ) { this.saveDialog.showMessage( 'api-save-error', $.parseHTML( editApi.warning ), { wrap: false } ); // 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. return; } // Handle token errors if ( data.error && data.error.code === 'badtoken' ) { api = new mw.Api(); viewPage.saveDialog.saveButton.setDisabled( true ); viewPage.saveDialog.$loadingIcon.show(); api.get( { // action=query&meta=userinfo and action=tokens&type=edit can't be combined // but action=query&meta=userinfo and action=query&prop=info can, however // that means we have to give it titles and deal with page ids. 'action': 'query', 'meta': 'userinfo', 'prop': 'info', // Try to send the normalised form so that it is less likely we get extra data like // data.normalised back that we don't need. 'titles': new mw.Title( viewPage.pageName ).toText(), 'indexpageids': '', 'intoken': 'edit' } ) .always( function () { viewPage.saveDialog.$loadingIcon.hide(); } ) .done( function ( data ) { var badTokenText, userMsg, userInfo = data.query && data.query.userinfo, pageInfo = data.query && data.query.pages && data.query.pageids && data.query.pageids[0] && data.query.pages[ data.query.pageids[0] ], editToken = pageInfo && pageInfo.edittoken; if ( userInfo && editToken ) { viewPage.editToken = editToken; if ( ( mw.user.isAnon() && userInfo.anon !== undefined ) || // Comparing id instead of name to pretect against possible // normalisation and against case where the user got renamed. mw.config.get( 'wgUserId' ) === userInfo.id ) { // New session is the same user still viewPage.saveDocument(); } else { // The now current session is a different user viewPage.saveDialog.saveButton.setDisabled( false ); // Trailing space is to separate from the other message. badTokenText = document.createTextNode( mw.msg( 'visualeditor-savedialog-error-badtoken' ) + ' ' ); if ( userInfo.anon !== undefined ) { // New session is an anonymous user mw.config.set( { // wgUserId is unset for anonymous users, not set to null 'wgUserId': undefined, // wgUserName is explicitly set to null for anonymous users, // functions like mw.user.isAnon rely on this. 'wgUserName': null } ); viewPage.saveDialog.showMessage( 'api-save-error', $( badTokenText ).add( $.parseHTML( mw.message( 'visualeditor-savedialog-identify-anon' ).parse() ) ), { wrap: 'warning' } ); } else { // New session is a different user mw.config.set( { 'wgUserId': userInfo.id, 'wgUserName': userInfo.name } ); // mediawiki.jqueryMsg has a bug with [[User:$1|$1]] (bug 51388) userMsg = 'visualeditor-savedialog-identify-user---' + userInfo.name; mw.messages.set( userMsg, mw.messages.get( 'visualeditor-savedialog-identify-user' ) .replace( /\$1/g, userInfo.name ) ); viewPage.saveDialog.showMessage( 'api-save-error', $( badTokenText ).add( $.parseHTML( mw.message( userMsg ).parse() ) ), { wrap: 'warning' } ); } } } } ); return; } // Handle captcha // Captcha "errors" usually aren't errors. We simply don't know about them ahead of time, // so we save once, then (if required) we get an error with a captcha back and try again after // the user solved the captcha. // TODO: ConfirmEdit API is horrible, there is no reliable way to know whether it is a "math", // "question" or "fancy" type of captcha. They all expose differently named properties in the // API for different things in the UI. At this point we only support the FancyCaptha which we // very intuitively detect by the presence of a "url" property. if ( editApi && editApi.captcha && editApi.captcha.url ) { this.captcha = { input: new ve.ui.TextInputWidget(), id: editApi.captcha.id }; this.saveDialog.showMessage( 'api-save-error', $( '
' ).append( // msg: simplecaptcha-edit, fancycaptcha-edit, .. $( '

' ).append( $( '' ).text( mw.msg( 'captcha-label' ) ), document.createTextNode( mw.msg( 'colon-separator' ) ), $( $.parseHTML( mw.message( 'fancycaptcha-edit' ).parse() ) ) .filter( 'a' ).attr( 'target', '_blank ' ).end() ), $( '' ).attr( 'src', editApi.captcha.url ), this.captcha.input.$ ), { wrap: false } ); return; } // Handle (other) unknown and/or unrecoverable errors this.saveDialog.showMessage( 'api-save-error', document.createTextNode( ( editApi && editApi.info ) || ( data.error && data.error.info ) || ( editApi && editApi.code ) || ( data.error && data.error.code ) || 'Unknown error' ), { wrap: 'error' } ); this.saveDialog.saveButton.setDisabled( true ); }; /** * Handle Show changes event. * * @method * @param {string} diffHtml */ ve.init.mw.ViewPageTarget.prototype.onShowChanges = function ( diffHtml ) { // Invalidate the viewer diff on next change this.surface.getModel().connect( this, { 'transact': 'onSurfaceModelTransact' } ); this.saveDialog.setDiffAndReview( diffHtml ); }; /** * Handle Serialize event. * * @method * @param {string} wikitext */ ve.init.mw.ViewPageTarget.prototype.onSerialize = function ( wikitext ) { // Invalidate the viewer wikitext on next change this.surface.getModel().connect( this, { 'transact': 'onSurfaceModelTransact' } ); this.saveDialog.setDiffAndReview( $( '

' ).text( wikitext ) );
};

/**
 * Handle failed show changes event.
 *
 * @method
 * @param {Object} jqXHR
 * @param {string} status Text status message
 */
ve.init.mw.ViewPageTarget.prototype.onShowChangesError = function ( jqXHR, status ) {
	alert( ve.msg( 'visualeditor-differror', status ) );
	this.saveDialog.$loadingIcon.hide();
};

/**
 * Called if a call to target.serialize() failed.
 *
 * @method
 * @param {jqXHR|null} jqXHR
 * @param {string} status Text status message
 */
ve.init.mw.ViewPageTarget.prototype.onSerializeError = function ( jqXHR, status ) {
	alert( ve.msg( 'visualeditor-serializeerror', status ) );
	this.saveDialog.$loadingIcon.hide();
};

/**
 * Handle edit conflict event.
 *
 * @method
 */
ve.init.mw.ViewPageTarget.prototype.onEditConflict = function () {
	this.saveDialog.$loadingIcon.hide();
	this.saveDialog.swapPanel( 'conflict' );
};

/**
 * Handle failed show changes event.
 *
 * @method
 */
ve.init.mw.ViewPageTarget.prototype.onNoChanges = function () {
	this.saveDialog.$loadingIcon.hide();
	this.saveDialog.swapPanel( 'nochanges' );
	this.saveDialog.reviewGoodButton.setDisabled( false );
};

/**
 * 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;
	}
	if ( this.active ) {
		this.deactivate();
		// Prevent the edit tab's normal behavior
		e.preventDefault();
	} else if ( this.activating ) {
		this.deactivate( true );
		this.activating = false;
		e.preventDefault();
	}
};

/**
 * Handle clicks on the save button in the toolbar.
 *
 * @method
 * @param {jQuery.Event} e Mouse click event
 */
ve.init.mw.ViewPageTarget.prototype.onToolbarSaveButtonClick = function () {
	if ( this.edited || this.restoring ) {
		this.showSaveDialog();
	}
};

/**
 * Handle clicks on the save button in the toolbar.
 *
 * @method
 * @param {jQuery.Event} e Mouse click event
 */
ve.init.mw.ViewPageTarget.prototype.onToolbarCancelButtonClick = function () {
	this.deactivate();
};

/**
 * Handle clicks on the MwMeta button in the toolbar.
 *
 * @method
 * @param {jQuery.Event} e Mouse click event
 */
ve.init.mw.ViewPageTarget.prototype.onToolbarMetaButtonClick = function () {
	this.surface.getDialogs().open( 'meta' );
};

/**
 * Handle clicks on the feedback tool in the toolbar.
 *
 * @method
 * @param {jQuery.Event} e Mouse click event
 */
ve.init.mw.ViewPageTarget.prototype.onToolbarFeedbackToolClick = function () {
	this.toolbarBetaNotices.hide();
	if ( !this.feedback ) {
		// This can't be constructed until the editor has loaded as it uses special messages
		this.feedback = new mw.Feedback( {
			'title': new mw.Title( ve.msg( 'visualeditor-feedback-link' ) ),
			'bugsLink': new mw.Uri( 'https://bugzilla.wikimedia.org/enter_bug.cgi?product=VisualEditor&component=General' ),
			'bugsListLink': new mw.Uri( 'https://bugzilla.wikimedia.org/buglist.cgi?query_format=advanced&resolution=---&resolution=LATER&resolution=DUPLICATE&product=VisualEditor&list_id=166234' )
		} );
	}
	this.feedback.launch();
};

/**
 * Handle the first transaction in the surface model.
 *
 * This handler is removed the first time it's used, but added each time the surface is set up.
 *
 * @method
 * @param {ve.dm.Transaction} tx Processed transaction
 */
ve.init.mw.ViewPageTarget.prototype.onSurfaceModelTransact = function () {
	// Clear the diff
	this.saveDialog.$reviewViewer.empty();
	this.surface.getModel().disconnect( this, { 'transact': 'onSurfaceModelTransact' } );
};

/**
 * Handle changes to the surface model.
 *
 * This is used to trigger notifications when the user starts entering wikitext
 *
 * @param {ve.dm.Transaction} tx
 * @param {ve.Range} range
 */
ve.init.mw.ViewPageTarget.prototype.onSurfaceModelChange = function ( tx, range ) {
	if ( !range ) {
		return;
	}
	var text, doc = this.surface.getView().getDocument(),
		node = doc.getNodeFromOffset( range.start );
	if ( !( node instanceof ve.ce.ContentBranchNode ) ) {
		return;
	}
	text = ve.ce.getDomText( node.$[0] );

	if ( text.match( /\[\[|\{\{|''|' ).append( ve.init.platform.getParsedMessage( 'missingsummary' ) ),
			{ wrap: false }
		);
	} else {
		this.saveDialog.saveButton.setDisabled( true );
		this.saveDialog.$loadingIcon.show();
		this.save(
			ve.dm.converter.getDomFromData( doc.getFullData(), doc.getStore(), doc.getInternalList() ),
			saveOptions
		);
	}
};

/**
 * Handle clicks on the resolve conflict button in the conflict dialog.
 *
 * @method
 */
ve.init.mw.ViewPageTarget.prototype.onSaveDialogResolveConflict= function () {
	var doc = this.surface.getModel().getDocument();
	// Get Wikitext from the DOM, and set up a submit call when it's done
	this.serialize(
		ve.dm.converter.getDomFromData( doc.getFullData(), doc.getStore(), doc.getInternalList() ),
		ve.bind( function ( wikitext ) {
			this.submit( wikitext, this.getSaveOptions() );
		}, this )
	);
};

/**
 * Get save options from the save dialog form.
 *
 * @method
 * @returns {Object} Save options, including summary, minor and watch properties
 */
ve.init.mw.ViewPageTarget.prototype.getSaveOptions = function () {
	var options = {
		'summary': this.saveDialog.editSummaryInput.$input.val(),
		'captchaid': this.captcha && this.captcha.id,
		'captchaword': this.captcha && this.captcha.input.getValue()
	};
	if ( this.sanityCheckPromise.state() === 'rejected' ) {
		options.needcheck = 1;
	}
	if ( this.saveDialog.$saveOptions.find( '#wpMinoredit' ).prop( 'checked' ) ) {
		options.minor = 1;
	}
	if ( this.saveDialog.$saveOptions.find( '#wpWatchthis' ).prop( 'checked' ) ) {
		options.watch = 1;
	}
	this.saveDialog.$saveOptions
		.find( '.ve-ui-mwSaveDialog-checkboxes' )
		.find( 'input:not(#wpMinoredit, #wpWatchthis)' )
		.each( function () {
			var $this = $( this );
			// We can't just use $this.val() because .val() always returns the value attribute of
			// a checkbox even when it's unchecked
			if ( $this.prop( 'type') !== 'checkbox' || $this.prop( 'checked' ) ) {
				options[$this.prop( 'name' )] = $this.val();
			}
		} );
	return options;
};

/**
 * Set up the list of edit notices.
 *
 * @method
 */
ve.init.mw.ViewPageTarget.prototype.setupToolbarEditNotices = function () {
	var key,
		$notices = $( '
' ); for ( key in this.editNotices ) { $notices.append( this.editNotices[key] ); } $notices .addClass( 've-init-mw-viewPageTarget-editNotices' ) .find( 'a' ).attr( 'target', '_blank' ); this.toolbarEditNotices = this.toolbarEditNoticesButton.getPopup(); this.toolbarEditNotices.$body.empty().append( $notices ); }; /** * Set up the beta notices panel. * * @method * @returns {string[]} HTML strings for each edit notice */ ve.init.mw.ViewPageTarget.prototype.setupToolbarBetaNotices = function () { var $notices = $( '
' ); $notices .addClass( 've-init-mw-viewPageTarget-betaNotices' ) .append( $( '
' ) .addClass( 've-init-mw-viewPageTarget-betaNotices-notice' ) .text( ve.msg( 'visualeditor-beta-warning' ) ) ) .append( $( '
' ) .addClass( 've-init-mw-viewPageTarget-betaNotices-notice' ) .append( this.toolbarHelpButton.$ ) .append( this.toolbarFeedbackButton.$ ) ); if ( ve.version.id !== false ) { $notices .append( $( '
' ) .addClass( 've-init-mw-viewPageTarget-betaNotices-notice' ) .append( $( '' ) .addClass( 've-init-mw-ViewPageTarget-version-label' ) .text( ve.msg( 'visualeditor-version-label' ) ) ) .append( ' ' ) .append( $( '' ) .addClass( 've-init-mw-ViewPageTarget-version-link' ) .attr( 'target', '_blank' ) .attr( 'href', ve.version.url ) .text( ve.version.id ) ) .append( ' ' ) .append( $( '' ) .addClass( 've-init-mw-ViewPageTarget-version-date' ) .text( ve.version.dateString ) ) ); } this.toolbarBetaNotices = this.toolbarBetaNoticesButton.getPopup(); this.toolbarBetaNotices.$body.empty().append( $notices ); }; /** * Switch to editing mode. * * @method * @param {HTMLDocument} doc HTML DOM to edit * @param {Function} [callback] Callback to call when done */ ve.init.mw.ViewPageTarget.prototype.setUpSurface = function ( doc, callback ) { var target = this; setTimeout( function () { // Build linmod var store = new ve.dm.IndexValueStore(), internalList = new ve.dm.InternalList(), data = ve.dm.converter.getDataFromDom( doc, store, internalList ); setTimeout( function () { // Build DM tree var dmDoc = new ve.dm.Document( data, undefined, internalList ); setTimeout( function () { // Create ui.Surface (also creates ce.Surface and dm.Surface and builds CE tree) target.surface = new ve.ui.Surface( dmDoc, target.surfaceOptions ); target.surface.$.addClass( 've-init-mw-viewPageTarget-surface' ); setTimeout( function () { // Initialize surface target.surface.getContext().hide(); target.$document = target.surface.$.find( '.ve-ce-documentNode' ); target.surface.getModel().connect( target, { 'transact': 'onSurfaceModelTransact', 'change': 'onSurfaceModelChange', 'history': 'updateToolbarSaveButtonState' } ); target.$.append( target.surface.$ ); target.setUpToolbar(); target.transformPageTitle(); target.changeDocumentTitle(); // Update UI target.hidePageContent(); target.hideSpinner(); target.active = true; target.$document.attr( { 'lang': mw.config.get( 'wgVisualEditor' ).pageLanguageCode, 'dir': mw.config.get( 'wgVisualEditor' ).pageLanguageDir } ); // Add appropriately mw-content-ltr or mw-content-rtl class target.surface.view.$.addClass( 'mw-content-' + mw.config.get( 'wgVisualEditor' ).pageLanguageDir ); // Now that the surface is attached to the document and ready, // let it initialize itself target.surface.initialize(); setTimeout( callback ); } ); } ); } ); } ); }; /** * Fire off the sanity check. Must be called before the surface is activated. * * To access the result, check whether #sanityCheckPromise has been resolved or rejected * (it's asynchronous, so it may still be pending when you check). */ ve.init.mw.ViewPageTarget.prototype.startSanityCheck = function () { // We have to get a copy of the data now, before we unlock the surface and let the user edit, // but we can defer the actual conversion and comparison var viewPage = this, doc = viewPage.surface.getModel().getDocument(), data = new ve.dm.FlatLinearData( doc.getStore().clone(), ve.copy( doc.getFullData() ) ), oldDom = viewPage.doc, d = $.Deferred(); // Reset viewPage.sanityCheckFinished = false; viewPage.sanityCheckVerified = false; setTimeout( function () { // We can't compare oldDom.body and newDom.body directly, because the attributes on the // were ignored in the conversion. So compare each child separately. var i, len = oldDom.body.childNodes.length, newDoc = new ve.dm.Document( data, undefined, doc.getInternalList() ), newDom = ve.dm.converter.getDomFromData( newDoc.getFullData(), newDoc.getStore(), newDoc.getInternalList() ); // Explicitly unlink our full copy of the original version of the document data data = undefined; if ( len !== newDom.body.childNodes.length ) { // Different number of children, so they're definitely different d.reject(); return; } for ( i = 0; i < len; i++ ) { if ( !oldDom.body.childNodes[i].isEqualNode( newDom.body.childNodes[i] ) ) { d.reject(); return; } } d.resolve(); } ); viewPage.sanityCheckPromise = d.promise() .done( function () { // If we detect no roundtrip errors, // don't emphasize "review changes" to the user. viewPage.sanityCheckVerified = true; }) .always( function () { viewPage.sanityCheckFinished = true; viewPage.updateToolbarSaveButtonState(); } ); }; /** * @see ve.ui.SurfaceToolbar#position * @param {jQuery} $bar * @param {Object} update */ ve.init.mw.ViewPageTarget.prototype.onToolbarPosition = function ( $bar, update ) { // It's important that the toolbar tracker always has 0 height, otherwise it will block events // on the toolbar (e.g. clicking "Save page") as it would overlap that space. The save dialog // will remain visible for the same reason elsewhere: As long as we don't have overflow:hidden, // the save dialog will stick out of the tracker in the right place without the tracker itself // blocking the toolbar. if ( !this.toolbarTrackerFloating && update.floating === true ) { // When switching to floating, undo the 'top' position set earlier this.$toolbarTracker.css( 'top', '' ); } if ( update.offset ) { this.toolbarOffset = update.offset; } if ( typeof update.floating === 'boolean' ) { this.$toolbarTracker.toggleClass( 've-init-mw-viewPageTarget-toolbarTracker-floating', update.floating ); this.toolbarTrackerFloating = update.floating; } // Switching to non-floating or offset update when already in non-floating if ( update.floating === false || this.toolbarTrackerFloating === false && update.offset ) { // Don't use update.css in this case since the toolbar is now in its non-floating // position (static, in-flow). So make the tracker absolutely postioned matching the // offset of the toolbar. this.$toolbarTracker.css( { 'top': this.toolbarOffset.top, 'left': this.toolbarOffset.left, 'right': this.toolbarOffset.right } ); } else if ( update.css ) { this.$toolbarTracker.css( update.css ); } }; /** * Switch to viewing mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.tearDownSurface = function () { // Update UI if ( this.$document ) { this.$document.blur(); this.$document = null; } this.tearDownToolbar(); this.restoreDocumentTitle(); // Destroy surface if ( this.surface ) { this.surface.destroy(); this.surface = null; } this.active = false; }; /** * Modify tabs in the skin to support in-place editing. * Edit tab is bound outside the module in mw.ViewPageTarget.init. * * @method */ ve.init.mw.ViewPageTarget.prototype.setupSkinTabs = function () { if ( this.isViewPage ) { // Allow instant switching back to view mode, without refresh $( '#ca-view a, #ca-nstab-visualeditor a' ) .click( ve.bind( this.onViewTabClick, this ) ); } mw.hook( 've.skinTabSetupComplete' ).fire(); }; /** * Modify page content to make section edit links activate the editor. * Dummy replaced by init.js so that we can call it again from #onSave after * replacing the page contents with the new html. */ ve.init.mw.ViewPageTarget.prototype.setupSectionEditLinks = null; /** * Add content and event bindings to toolbar buttons. * * @method */ ve.init.mw.ViewPageTarget.prototype.setupToolbarButtons = function () { var editNoticeCount = ve.getObjectKeys( this.editNotices ).length; this.toolbarCancelButton = new ve.ui.PushButtonWidget( { 'label': ve.msg( 'visualeditor-toolbar-cancel' ) } ); this.toolbarCancelButton.$.addClass( 've-ui-toolbar-cancelButton' ); this.toolbarSaveButton = new ve.ui.PushButtonWidget( { 'label': ve.msg( 'visualeditor-toolbar-savedialog' ), 'flags': ['constructive'], 'disabled': !this.restoring } ); // TODO (mattflaschen, 2013-06-27): it would be useful to do this in a more general way, such // as in the ButtonWidget constructor. this.toolbarSaveButton.$.addClass( 've-ui-toolbar-saveButton' ); this.updateToolbarSaveButtonState(); this.toolbarCancelButton.connect( this, { 'click': 'onToolbarCancelButtonClick' } ); this.toolbarSaveButton.connect( this, { 'click': 'onToolbarSaveButtonClick' } ); this.toolbarMetaButton = new ve.ui.IconButtonWidget( { 'icon': 'settings', 'label': ve.msg( 'visualeditor-meta-tool' ) } ); this.toolbarMetaButton.connect( this, { 'click': 'onToolbarMetaButtonClick' } ); this.toolbarEditNoticesButton = new ve.ui.PopupButtonWidget( { 'icon': 'alert', 'popup': { 'label': ve.msg( 'visualeditor-editnotices-tool', editNoticeCount ), 'head': true } } ); // Either keep the button shown and show the popup as well, or hide the button itself // entirely (so that when it is attached later #attachToolbarButtons it will not be visible). if ( editNoticeCount ) { setTimeout( ve.bind( function () { this.toolbarEditNoticesButton.showPopup(); }, this ), 500 ); } else { this.toolbarEditNoticesButton.$.hide(); } this.toolbarBetaNoticesButton = new ve.ui.PopupButtonWidget( { 'icon': 'help', 'popup': { 'label': ve.msg( 'visualeditor-help-tool' ), 'head': true } } ); this.toolbarBetaNoticesButton.$label.addClass( 've-init-mw-viewPageTarget-tool-beta-label' ); this.toolbarHelpButton = new ve.ui.IconButtonWidget( { 'icon': 'help', 'title': ve.msg( 'visualeditor-help-title' ), 'href': new mw.Title( ve.msg( 'visualeditor-help-link' ) ).getUrl(), 'target': '_blank', 'label': ve.msg( 'visualeditor-help-label' ) } ); this.toolbarFeedbackButton = new ve.ui.IconButtonWidget( { 'icon': 'comment', 'label': ve.msg( 'visualeditor-feedback-tool' ) } ); this.toolbarFeedbackButton.connect( this, { 'click': 'onToolbarFeedbackToolClick' } ); }; /** * Remove content and event bindings from toolbar buttons. * * @method */ ve.init.mw.ViewPageTarget.prototype.tearDownToolbarButtons = function () { this.toolbarCancelButton.disconnect( this ); this.toolbarSaveButton.disconnect( this ); this.toolbarMetaButton.disconnect( this ); this.toolbarFeedbackButton.disconnect( this ); this.toolbarEditNotices.$body.empty(); this.toolbarBetaNotices.$body.empty(); }; /** * Add the save button to the user interface. * * @method */ ve.init.mw.ViewPageTarget.prototype.attachToolbarButtons = function () { var $iconButtons = $( '
' ), $pushButtons = $( '
' ); $iconButtons .addClass( 've-init-mw-viewPageTarget-toolbar-utilites' ) .append( this.toolbarBetaNoticesButton.$, this.toolbarEditNoticesButton.$, this.toolbarMetaButton.$ ); $pushButtons .addClass( 've-init-mw-viewPageTarget-toolbar-actions' ) .append( this.toolbarCancelButton.$, this.toolbarSaveButton.$ ); this.toolbar.$actions.append( $iconButtons, $pushButtons ); }; /** * Remove the save button from the user interface. * * @method */ ve.init.mw.ViewPageTarget.prototype.detachToolbarButtons = function () { this.toolbarCancelButton.$.detach(); this.toolbarSaveButton.$.detach(); this.toolbarMetaButton.$.detach(); this.toolbarHelpButton.$.detach(); this.toolbarFeedbackButton.$.detach(); this.toolbarBetaNoticesButton.$.detach(); this.toolbarEditNoticesButton.$.detach(); this.toolbar.$actions.empty(); }; /** * Add content and event bindings to the save dialog. * * @method */ ve.init.mw.ViewPageTarget.prototype.setupSaveDialog = function () { var sectionTitle = '', viewPage = this; viewPage.saveDialog = this.surface.getDialogs().getWindow( 'mwSave' ); if ( viewPage.section ) { sectionTitle = viewPage.$document.find( 'h1, h2, h3, h4, h5, h6' ).eq( viewPage.section - 1 ).text(); sectionTitle = '/* ' + ve.graphemeSafeSubstring( sectionTitle, 0, 244 ) + ' */ '; viewPage.saveDialog.editSummaryInput.$input.val( sectionTitle ); viewPage.sectionTitleRestored = true; if ( viewPage.sectionPositionRestored ) { viewPage.onSectionRestored(); } } // Connect to save dialog viewPage.saveDialog.connect( this, { 'save': 'onSaveDialogSave', 'review': 'onSaveDialogReview', 'resolve': 'onSaveDialogResolveConflict' } ); // Setup checkboxes viewPage.saveDialog.setupCheckboxes( ve.getObjectValues( viewPage.checkboxes ).join( '\n' ) ); }; /** * Show the save dialog. * * @method */ ve.init.mw.ViewPageTarget.prototype.showSaveDialog = function () { this.toolbarBetaNotices.hide(); this.toolbarEditNotices.hide(); this.saveDialog.setSanityCheck( this.sanityCheckVerified ); this.saveDialog.swapPanel( 'save' ); this.surface.getDialogs().open( 'mwSave' ); }; /** * Remember the window's scroll position. * * @method */ ve.init.mw.ViewPageTarget.prototype.saveScrollPosition = function () { this.scrollTop = $( window ).scrollTop(); }; /** * Restore the window's scroll position. * * @method */ ve.init.mw.ViewPageTarget.prototype.restoreScrollPosition = function () { if ( this.scrollTop ) { $( window ).scrollTop( this.scrollTop ); this.scrollTop = null; } }; /** * Show the loading spinner. * * @method */ ve.init.mw.ViewPageTarget.prototype.showSpinner = function () { $( '#firstHeading' ).prepend( this.$spinner ); }; /** * Hide the loading spinner. * * @method */ ve.init.mw.ViewPageTarget.prototype.hideSpinner = function () { this.$spinner.detach(); }; /** * Show the page content. * * @method */ ve.init.mw.ViewPageTarget.prototype.showPageContent = function () { $( '#bodyContent > .ve-init-mw-viewPageTarget-content' ) .removeClass( 've-init-mw-viewPageTarget-content' ) .show() .fadeTo( 0, 1 ); }; /** * Mute the page content. * * @method */ ve.init.mw.ViewPageTarget.prototype.mutePageContent = function () { $( '#bodyContent > :visible:not(#siteSub, #contentSub)' ) .addClass( 've-init-mw-viewPageTarget-content' ) .fadeTo( 'fast', 0.6 ); }; /** * Hide the page content. * * @method */ ve.init.mw.ViewPageTarget.prototype.hidePageContent = function () { $( '#bodyContent > :visible:not(#siteSub, #contentSub)' ) .addClass( 've-init-mw-viewPageTarget-content' ) .hide(); }; /** * Show the table of contents in the view mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.showTableOfContents = function () { var $toc = $( '#toc' ), $wrap = $toc.parent(); if ( $wrap.data( 've.hideTableOfContents' ) ) { $wrap.slideDown( function () { $toc.unwrap(); } ); } }; /** * Hide the table of contents in the view mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.hideTableOfContents = function () { $( '#toc' ) .wrap( '
' ) .parent() .data( 've.hideTableOfContents', true ) .slideUp(); }; /** * Show the toolbar. * * This also transplants the toolbar to a new location. * * @method */ ve.init.mw.ViewPageTarget.prototype.setUpToolbar = function () { this.toolbar = new ve.ui.TargetToolbar( this, this.surface, { 'shadow': true, 'actions': true } ); this.toolbar.connect( this, { 'position': 'onToolbarPosition' } ); this.toolbar.setup( this.constructor.static.toolbarGroups ); this.surface.addCommands( this.constructor.static.surfaceCommands ); if ( !this.isMobileDevice ) { this.toolbar.enableFloatable(); } this.toolbar.$ .addClass( 've-init-mw-viewPageTarget-toolbar' ) .insertBefore( '#firstHeading' ); this.toolbar.$bar.slideDown( 'fast', ve.bind( function () { // Check the surface wasn't torn down while the toolbar was animating if ( this.surface ) { this.toolbar.initialize(); this.surface.emit( 'position' ); this.surface.getContext().update(); } }, this ) ); }; /** * Hide the toolbar. * * @method */ ve.init.mw.ViewPageTarget.prototype.tearDownToolbar = function () { this.toolbar.$bar.slideUp( 'fast', ve.bind( function () { this.toolbar.destroy(); this.toolbar = null; }, this ) ); }; /** * Transform the page title into a VE-style title. * * @method */ ve.init.mw.ViewPageTarget.prototype.transformPageTitle = function () { $( '#firstHeading' ).addClass( 've-init-mw-viewPageTarget-pageTitle' ); }; /** * Fade the page title to indicate it is not editable. * * @method */ ve.init.mw.ViewPageTarget.prototype.mutePageTitle = function () { $( '#firstHeading, #siteSub' ) .addClass( 've-init-mw-viewPageTarget-transform ve-init-mw-viewPageTarget-transform-muted' ); $( '#contentSub' ) .addClass( 've-init-mw-viewPageTarget-transform ve-init-mw-viewPageTarget-transform-hidden' ); }; /** * Restore the page title to its original style. * * @method */ ve.init.mw.ViewPageTarget.prototype.restorePageTitle = function () { var $els = $( '#firstHeading, #siteSub, #contentSub' ) .removeClass( 've-init-mw-viewPageTarget-transform-muted ve-init-mw-viewPageTarget-transform-hidden' ); setTimeout( function () { $els.removeClass( 've-init-mw-viewPageTarget-transform' ); $( '#firstHeading' ).removeClass( 've-init-mw-viewPageTarget-pageTitle' ); }, 1000 ); }; /** * Change the document title to state that we are now editing. * * @method */ ve.init.mw.ViewPageTarget.prototype.changeDocumentTitle = function () { document.title = ve.msg( this.pageExists ? 'editing' : 'creating', mw.config.get( 'wgTitle' ) ) + ' - ' + mw.config.get( 'wgSiteName' ); }; /** * Restore the original document title. * * @method */ ve.init.mw.ViewPageTarget.prototype.restoreDocumentTitle = function () { document.title = this.originalDocumentTitle; }; /** * Page modifications for switching to edit mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.transformPage = function () { var uri; // Put skin tabs in "edit" mode $( $( '#p-views' ).length ? '#p-views' : '#p-cactions' ) .find( 'li.selected' ).removeClass( 'selected' ); $( '#ca-ve-edit' ) .addClass( 'selected' ); // Hide site notice (if present) $( '#siteNotice:visible' ) .addClass( 've-hide' ) .slideUp( 'fast' ); // Push veaction=edit url in history (if not already. If we got here by a veaction=edit // permalink then it will be there already and the constructor called #activate) if ( !this.actFromPopState && window.history.pushState && this.currentUri.query.veaction !== 'edit' ) { // Set the veaction query parameter uri = this.currentUri; uri.query.veaction = 'edit'; window.history.pushState( null, document.title, uri ); } this.actFromPopState = false; }; /** * Page modifications for switching back to view mode. * * @method */ ve.init.mw.ViewPageTarget.prototype.restorePage = function () { var uri; // Put skin tabs back in "view" mode $( $( '#p-views' ).length ? '#p-views' : '#p-cactions' ) .find( 'li.selected' ).removeClass( 'selected' ); $( '#ca-view' ).addClass( 'selected' ); // Make site notice visible again (if present) $( '#siteNotice.ve-hide' ) .slideDown( 'fast' ); // Push non-veaction=edit url in history if ( !this.actFromPopState && window.history.pushState ) { // Remove the veaction query parameter uri = this.currentUri; if ( 'veaction' in uri.query ) { delete uri.query.veaction; } // If there are other query parameters, set the url to the current url (with veaction removed). // Otherwise use the canonical style view url (bug 42553). if ( ve.getObjectValues( uri.query ).length ) { window.history.pushState( null, document.title, uri ); } else { window.history.pushState( null, document.title, this.viewUri ); } } this.actFromPopState = false; }; /** * @param {Event} e Native event object */ ve.init.mw.ViewPageTarget.prototype.onWindowPopState = function () { var newUri = this.currentUri = new mw.Uri( document.location.href ); if ( !this.active && newUri.query.veaction === 'edit' ) { this.actFromPopState = true; this.activate(); } if ( this.active && newUri.query.veaction !== 'edit' ) { this.actFromPopState = true; this.deactivate(); } }; /** * Replace the page content with new HTML. * * @method * @param {HTMLElement} html Rendered HTML from server */ ve.init.mw.ViewPageTarget.prototype.replacePageContent = function ( html ) { var $content = $( $.parseHTML( html ) ); mw.hook( 'wikipage.content' ).fire( $( '#mw-content-text' ).empty().append( $content ) ); }; /** * Get the numeric index of a section in the page. * * @method * @param {HTMLElement} heading Heading element of section */ ve.init.mw.ViewPageTarget.prototype.getEditSection = function ( heading ) { var $page = $( '#mw-content-text' ), section = 0; $page.find( 'h1, h2, h3, h4, h5, h6' ).not( '#toc h2' ).each( function () { section++; if ( this === heading ) { return false; } } ); return section; }; /** * Store the section for which the edit link has been triggered. * * @method * @param {HTMLElement} heading Heading element of section */ ve.init.mw.ViewPageTarget.prototype.saveEditSection = function ( heading ) { this.section = this.getEditSection( heading ); }; /** * Move the cursor in the editor to a given section. * * @method * @param {number} section Section to move cursor to */ ve.init.mw.ViewPageTarget.prototype.restoreEditSection = function () { if ( this.section !== null ) { var offset, target = this, surfaceView = this.surface.getView(), surfaceModel = surfaceView.getModel(); this.$document.find( 'h1, h2, h3, h4, h5, h6' ).eq( this.section - 1 ).each( function () { var offsetNode, nextNode, headingNode = $( this ).data( 'view' ), lastHeadingLevel = -1; if ( headingNode ) { // Find next sibling which isn't a heading offsetNode = headingNode; while ( offsetNode instanceof ve.ce.HeadingNode && offsetNode.getModel().getAttribute( 'level' ) > lastHeadingLevel ) { lastHeadingLevel = offsetNode.getModel().getAttribute( 'level' ); // Next sibling nextNode = offsetNode.parent.children[ve.indexOf( offsetNode, offsetNode.parent.children ) + 1]; if ( !nextNode ) { break; } offsetNode = nextNode; } offset = surfaceModel.getDocument().data.getNearestContentOffset( offsetNode.getModel().getOffset(), 1 ); surfaceModel.change( null, new ve.Range( offset ) ); // Scroll to heading: // Wait for toolbar to animate in so we can account for its height setTimeout( function () { var $window = $( ve.Element.getWindow( target.$ ) ); $window.scrollTop( headingNode.$.offset().top - target.toolbar.$.height() ); }, 200 ); } } ); this.sectionPositionRestored = true; if ( this.sectionTitleRestored ) { this.onSectionRestored(); } } }; /** * Handle restoration of section editing position and title */ ve.init.mw.ViewPageTarget.prototype.onSectionRestored = function () { this.section = null; this.sectionPositionRestored = false; this.sectionTitleRestored = false; }; /** * Add onbeforunload handler. * * @method */ ve.init.mw.ViewPageTarget.prototype.setupBeforeUnloadHandler = function () { // Remember any already set on before unload handler this.onBeforeUnloadFallback = window.onbeforeunload; // Attach before unload handler window.onbeforeunload = this.onBeforeUnloadHandler = ve.bind( this.onBeforeUnload, this ); // Attach page show handlers if ( window.addEventListener ) { window.addEventListener( 'pageshow', ve.bind( this.onPageShow, this ), false ); } else if ( window.attachEvent ) { window.attachEvent( 'pageshow', ve.bind( this.onPageShow, this ) ); } }; /** * Remove onbeforunload handler. * * @method */ ve.init.mw.ViewPageTarget.prototype.tearDownBeforeUnloadHandler = function () { // Restore whatever previous onbeforeload hook existed window.onbeforeunload = this.onBeforeUnloadFallback; }; /** * Show beta welcome dialog if first load. */ ve.init.mw.ViewPageTarget.prototype.showBetaWelcome = function () { if ( $.cookie( 've-beta-welcome-dialog' ) === null ) { this.surface.getDialogs().open( 'betaWelcome' ); } $.cookie( 've-beta-welcome-dialog', 1, { 'path': '/', 'expires': 30 } ); }; /** * Handle page show event. * * @method */ ve.init.mw.ViewPageTarget.prototype.onPageShow = function () { // Re-add onbeforeunload handler window.onbeforeunload = this.onBeforeUnloadHandler; }; /** * Handle before unload event. * * @method */ ve.init.mw.ViewPageTarget.prototype.onBeforeUnload = function () { var fallbackResult, message, onBeforeUnloadHandler = this.onBeforeUnloadHandler; // Check if someone already set on onbeforeunload hook if ( this.onBeforeUnloadFallback ) { // Get the result of their onbeforeunload hook fallbackResult = this.onBeforeUnloadFallback(); } // Check if their onbeforeunload hook returned something if ( fallbackResult !== undefined ) { // Exit here, returning their message message = fallbackResult; } else { // Override if submitting if ( this.submitting ) { return null; } // Check if there's been an edit if ( this.surface && this.edited ) { // Return our message message = ve.msg( 'visualeditor-viewpage-savewarning' ); } } // Unset the onbeforeunload handler so we don't break page caching in Firefox window.onbeforeunload = null; if ( message !== undefined ) { // ...but if the user chooses not to leave the page, we need to rebind it setTimeout( function () { window.onbeforeunload = onBeforeUnloadHandler; } ); return message; } };