mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-12-23 19:43:19 +00:00
b9b966de6e
See I9addf43b2857de7bcac. Change-Id: Ic1998ab6f7dedf6946d7ae63a985a76031f8eecf
2527 lines
80 KiB
JavaScript
2527 lines
80 KiB
JavaScript
/*!
|
|
* VisualEditor MediaWiki Initialization ArticleTarget class.
|
|
*
|
|
* @copyright See AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
|
|
/* eslint-disable no-jquery/no-global-selector */
|
|
|
|
/**
|
|
* Initialization MediaWiki article target.
|
|
*
|
|
* @class
|
|
* @extends ve.init.mw.Target
|
|
*
|
|
* @constructor
|
|
* @param {Object} [config] Configuration options
|
|
* @cfg {Object} [toolbarConfig]
|
|
* @cfg {boolean} [register=true]
|
|
*/
|
|
ve.init.mw.ArticleTarget = function VeInitMwArticleTarget( config ) {
|
|
config = config || {};
|
|
config.toolbarConfig = ve.extendObject( {
|
|
shadow: true,
|
|
actions: true,
|
|
floatable: true
|
|
}, config.toolbarConfig );
|
|
|
|
// Parent constructor
|
|
ve.init.mw.ArticleTarget.super.call( this, config );
|
|
|
|
// Register
|
|
if ( config.register !== false ) {
|
|
// ArticleTargets are never destroyed, but we can't trust ve.init.target to
|
|
// not get overridden by other targets that may get created on the page.
|
|
ve.init.articleTarget = this;
|
|
}
|
|
|
|
// Properties
|
|
this.saveDialog = null;
|
|
this.saveDeferred = null;
|
|
this.saveFields = {};
|
|
this.docToSave = null;
|
|
this.originalDmDocPromise = null;
|
|
this.originalHtml = null;
|
|
this.toolbarSaveButton = null;
|
|
this.pageExists = mw.config.get( 'wgRelevantArticleId', 0 ) !== 0;
|
|
const enableVisualSectionEditing = mw.config.get( 'wgVisualEditorConfig' ).enableVisualSectionEditing;
|
|
this.enableVisualSectionEditing = enableVisualSectionEditing === true || enableVisualSectionEditing === this.constructor.static.trackingName;
|
|
this.toolbarScrollOffset = mw.config.get( 'wgVisualEditorToolbarScrollOffset', 0 );
|
|
this.currentUrl = new URL( location.href );
|
|
this.section = null;
|
|
this.visibleSection = null;
|
|
this.visibleSectionOffset = null;
|
|
this.sectionTitle = null;
|
|
this.editSummaryValue = null;
|
|
this.initialEditSummary = null;
|
|
this.initialCheckboxes = {};
|
|
|
|
this.viewUrl = new URL( mw.util.getUrl( this.getPageName() ), location.href );
|
|
this.isViewPage = (
|
|
mw.config.get( 'wgAction' ) === 'view' &&
|
|
!this.currentUrl.searchParams.has( 'diff' )
|
|
);
|
|
|
|
this.copyrightWarning = null;
|
|
this.checkboxFields = null;
|
|
this.checkboxesByName = null;
|
|
this.$saveAccessKeyElements = null;
|
|
|
|
this.$editableContent = this.getEditableContent();
|
|
|
|
// Sometimes we actually don't want to send a useful oldid
|
|
// if we do, PostEdit will give us a 'page restored' message
|
|
// Use undefined instead of 0 for new documents (T262838)
|
|
this.requestedRevId = mw.config.get( 'wgEditLatestRevision' ) ? mw.config.get( 'wgCurRevisionId' ) : mw.config.get( 'wgRevisionId' ) || undefined;
|
|
this.currentRevisionId = mw.config.get( 'wgCurRevisionId' ) || undefined;
|
|
this.revid = this.requestedRevId || this.currentRevisionId;
|
|
|
|
this.edited = false;
|
|
this.restoring = !!this.requestedRevId && this.requestedRevId !== this.currentRevisionId;
|
|
this.pageDeletedWarning = false;
|
|
this.events = {
|
|
track: () => {},
|
|
trackActivationStart: () => {},
|
|
trackActivationComplete: () => {}
|
|
};
|
|
|
|
this.preparedCacheKeyPromise = null;
|
|
this.clearState();
|
|
|
|
// Initialization
|
|
this.$element.addClass( 've-init-mw-articleTarget' );
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
OO.inheritClass( ve.init.mw.ArticleTarget, ve.init.mw.Target );
|
|
|
|
/* Events */
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#save
|
|
* @param {Object} data Save data from the API, see ve.init.mw.ArticleTarget#saveComplete
|
|
* Fired immediately after a save is successfully completed
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#savePreview
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#saveReview
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#saveInitiated
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#saveWorkflowBegin
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#showChanges
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#noChanges
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#saveError
|
|
* @param {string} code Error code
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#loadError
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#showChangesError
|
|
*/
|
|
|
|
/**
|
|
* @event ve.init.mw.ArticleTarget#serializeError
|
|
*/
|
|
|
|
/**
|
|
* Fired when serialization is complete
|
|
*
|
|
* @event ve.init.mw.ArticleTarget#serializeComplete
|
|
*/
|
|
|
|
/* Static Properties */
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.name = 'article';
|
|
|
|
/**
|
|
* Tracking name of target class. Used by ArticleTargetEvents to identify which target we are tracking.
|
|
*
|
|
* @static
|
|
* @property {string}
|
|
* @inheritable
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.trackingName = 'mwTarget';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.integrationType = 'page';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.platformType = 'other';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.documentCommands = [
|
|
...ve.init.mw.ArticleTarget.super.static.documentCommands,
|
|
// Make help dialog triggerable from anywhere
|
|
'commandHelp',
|
|
// Make save commands triggerable from anywhere
|
|
'showSave',
|
|
'showChanges',
|
|
'showPreview',
|
|
'showMinoredit',
|
|
'showWatchthis'
|
|
];
|
|
|
|
/* Static methods */
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.parseDocument = function ( documentString, mode, section, onlySection ) {
|
|
// Add trailing linebreak to non-empty wikitext documents for consistency
|
|
// with old editor and usability. Will be stripped on save. T156609
|
|
if ( mode === 'source' && documentString ) {
|
|
documentString += '\n';
|
|
}
|
|
|
|
// Parent method
|
|
return ve.init.mw.ArticleTarget.super.static.parseDocument.call( this, documentString, mode, section, onlySection );
|
|
};
|
|
|
|
/**
|
|
* Get the editable part of the page
|
|
*
|
|
* @return {jQuery} Editable DOM selection
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getEditableContent = function () {
|
|
return $( '#mw-content-text' );
|
|
};
|
|
|
|
/**
|
|
* Build DOM for the redirect page subtitle (#redirectsub).
|
|
*
|
|
* @return {jQuery}
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.buildRedirectSub = function () {
|
|
const $subMsg = mw.message( 'redirectpagesub' ).parseDom();
|
|
// Page subtitle
|
|
// Compare: Article::view()
|
|
return $( '<span>' )
|
|
.attr( 'id', 'redirectsub' )
|
|
.append( $subMsg );
|
|
};
|
|
|
|
/**
|
|
* Build DOM for the redirect page content header (.redirectMsg).
|
|
*
|
|
* @param {string} title Redirect target
|
|
* @return {jQuery}
|
|
*/
|
|
ve.init.mw.ArticleTarget.static.buildRedirectMsg = function ( title ) {
|
|
const $link = $( '<a>' )
|
|
.attr( {
|
|
href: mw.Title.newFromText( title ).getUrl(),
|
|
title: mw.msg( 'visualeditor-redirect-description', title )
|
|
} )
|
|
.text( title );
|
|
ve.init.platform.linkCache.styleElement( title, $link );
|
|
|
|
// Page content header
|
|
// Compare: LinkRenderer::makeRedirectHeader()
|
|
return $( '<div>' )
|
|
.addClass( 'redirectMsg' )
|
|
// Hack: This is normally inside #mw-content-text, but we may insert it before, so we need this.
|
|
// The following classes are used here:
|
|
// * mw-content-ltr
|
|
// * mw-content-rtl
|
|
.addClass( 'mw-content-' + mw.config.get( 'wgVisualEditor' ).pageLanguageDir )
|
|
.append(
|
|
$( '<p>' ).text( mw.msg( 'redirectto' ) ),
|
|
$( '<ul>' )
|
|
.addClass( 'redirectText' )
|
|
.append( $( '<li>' ).append( $link ) )
|
|
);
|
|
};
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setDefaultMode = function () {
|
|
const oldDefaultMode = this.defaultMode;
|
|
// Parent method
|
|
ve.init.mw.ArticleTarget.super.prototype.setDefaultMode.apply( this, arguments );
|
|
|
|
if ( this.defaultMode !== oldDefaultMode ) {
|
|
this.updateTabs();
|
|
if ( mw.libs.ve.setEditorPreference ) {
|
|
// only set up by DAT.init
|
|
mw.libs.ve.setEditorPreference( this.defaultMode === 'visual' ? 'visualeditor' : 'wikitext' );
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Update state of editing tabs from this target
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.updateTabs = function () {};
|
|
|
|
/**
|
|
* Handle response to a successful load request.
|
|
*
|
|
* This method is called within the context of a target instance. If successful the DOM from the
|
|
* server will be parsed, stored in {this.doc} and then {this.documentReady} will be called.
|
|
*
|
|
* @param {Object} response API response data
|
|
* @param {string} status Text status message
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.loadSuccess = function ( response ) {
|
|
const data = response ? ( response.visualeditor || response.visualeditoredit ) : null;
|
|
|
|
if ( !data || typeof data.content !== 'string' ) {
|
|
this.loadFail( 've-api', { errors: [ {
|
|
code: 've-api',
|
|
html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
|
|
} ] } );
|
|
} else if ( response.veMode && response.veMode !== this.getDefaultMode() ) {
|
|
this.loadFail( 've-mode', { errors: [ {
|
|
code: 've-mode',
|
|
html: mw.message( 'visualeditor-loaderror-wrongmode',
|
|
response.veMode, this.getDefaultMode() ).parse()
|
|
} ] } );
|
|
} else {
|
|
this.track( 'trace.parseResponse.enter' );
|
|
this.originalHtml = data.content;
|
|
this.etag = data.etag;
|
|
// We are reading from `preloaded` which comes from the VE API. If we want
|
|
// to make the VE API non-blocking in the future we will need to handle
|
|
// special-cases like this where the content doesn't come from RESTBase.
|
|
this.fromEditedState = !!data.fromEditedState || !!data.preloaded;
|
|
this.switched = data.switched;
|
|
const mode = this.getDefaultMode();
|
|
const section = ( mode === 'source' || this.enableVisualSectionEditing ) ? this.section : null;
|
|
this.doc = this.constructor.static.parseDocument( this.originalHtml, mode, section );
|
|
this.originalDmDocPromise = null;
|
|
|
|
// Properties that don't come from the API
|
|
this.initialSourceRange = data.initialSourceRange;
|
|
this.recovered = data.recovered;
|
|
this.isRedirect = false;
|
|
|
|
// Parse data this not available in RESTBase
|
|
if ( !this.parseMetadata( response ) ) {
|
|
// Invalid metadata, loadFail() or load() has been called
|
|
return;
|
|
}
|
|
|
|
this.track( 'trace.parseResponse.exit' );
|
|
|
|
// Everything worked, the page was loaded, continue initializing the editor
|
|
this.documentReady( this.doc );
|
|
}
|
|
|
|
if ( !this.isViewPage ) {
|
|
$( '#firstHeading' ).text(
|
|
mw.Title.newFromText( this.getPageName() ).getPrefixedText()
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Parse document metadata from the API response
|
|
*
|
|
* @param {Object} response API response data
|
|
* @return {boolean} Whether metadata was loaded successfully. If true, you should call
|
|
* loadSuccess(). If false, either that loadFail() has been called or we're retrying via load().
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.parseMetadata = function ( response ) {
|
|
const data = response ? ( response.visualeditor || response.visualeditoredit ) : null;
|
|
|
|
if ( !data ) {
|
|
this.loadFail( 've-api', { errors: [ {
|
|
code: 've-api',
|
|
html: mw.message( 'api-clientside-error-invalidresponse' ).parse()
|
|
} ] } );
|
|
return false;
|
|
}
|
|
|
|
this.remoteNotices = ve.getObjectValues( data.notices );
|
|
this.protectedClasses = data.protectedClasses;
|
|
|
|
this.baseTimeStamp = data.basetimestamp;
|
|
this.startTimeStamp = data.starttimestamp;
|
|
this.revid = data.oldid || undefined;
|
|
this.preloaded = !!data.preloaded;
|
|
|
|
this.copyrightWarning = data.copyrightWarning;
|
|
|
|
this.checkboxesDef = data.checkboxesDef;
|
|
this.checkboxesMessages = data.checkboxesMessages;
|
|
mw.messages.set( data.checkboxesMessages );
|
|
|
|
this.canEdit = data.canEdit;
|
|
this.wouldautocreate = data.wouldautocreate;
|
|
|
|
// When docRevId is `undefined` it indicates that the page doesn't exist
|
|
let docRevId;
|
|
const aboutDoc = this.doc.documentElement && this.doc.documentElement.getAttribute( 'about' );
|
|
if ( aboutDoc ) {
|
|
const docRevIdMatches = aboutDoc.match( /revision\/([0-9]*)$/ );
|
|
if ( docRevIdMatches.length >= 2 ) {
|
|
docRevId = parseInt( docRevIdMatches[ 1 ] );
|
|
}
|
|
}
|
|
// There is no docRevId in source mode (doc is just a string), new visual documents, or when
|
|
// switching from source mode with changes.
|
|
if ( this.getDefaultMode() === 'visual' && !( this.switched && this.fromEditedState ) && docRevId !== this.revid ) {
|
|
if ( this.retriedRevIdConflict ) {
|
|
// Retried already, just error the second time.
|
|
this.loadFail( 've-api', { errors: [ {
|
|
code: 've-api',
|
|
html: mw.message( 'visualeditor-loaderror-revidconflict',
|
|
String( docRevId ), String( this.revid ) ).parse()
|
|
} ] } );
|
|
} else {
|
|
this.retriedRevIdConflict = true;
|
|
// TODO this retries both requests, in RESTbase mode we should only retry
|
|
// the request that gave us the lower revid
|
|
this.loading = null;
|
|
// HACK: Load with explicit revid to hopefully prevent this from happening again
|
|
this.requestedRevId = Math.max( docRevId || 0, this.revid );
|
|
this.load();
|
|
}
|
|
return false;
|
|
} else {
|
|
// Set this to false after a successful load, so we don't immediately give up
|
|
// if a subsequent load mismatches again
|
|
this.retriedRevIdConflict = false;
|
|
}
|
|
|
|
// Save dialog doesn't exist yet, so create an overlay for the widgets, and
|
|
// append it to the save dialog later.
|
|
this.$saveDialogOverlay = $( '<div>' ).addClass( 'oo-ui-window-overlay' );
|
|
const checkboxes = mw.libs.ve.targetLoader.createCheckboxFields( this.checkboxesDef, { $overlay: this.$saveDialogOverlay } );
|
|
this.checkboxFields = checkboxes.checkboxFields;
|
|
this.checkboxesByName = checkboxes.checkboxesByName;
|
|
|
|
this.checkboxFields.forEach( ( field ) => {
|
|
// TODO: This method should be upstreamed or moved so that targetLoader
|
|
// can use it safely.
|
|
ve.targetLinksToNewWindow( field.$label[ 0 ] );
|
|
} );
|
|
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.documentReady = function () {
|
|
// We need to wait until documentReady as local notices may require special messages
|
|
this.editNotices = this.remoteNotices.concat(
|
|
this.localNoticeMessages.map( ( msgKey ) => '<p>' + ve.init.platform.getParsedMessage( msgKey ) + '</p>' )
|
|
);
|
|
|
|
this.loading = null;
|
|
this.edited = this.fromEditedState;
|
|
|
|
// Parent method
|
|
ve.init.mw.ArticleTarget.super.prototype.documentReady.apply( this, arguments );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.surfaceReady = function () {
|
|
const accessKeyPrefix = $.fn.updateTooltipAccessKeys.getAccessKeyPrefix().replace( /-/g, '+' ),
|
|
accessKeyModifiers = new ve.ui.Trigger( accessKeyPrefix + '-' ).modifiers,
|
|
surfaceModel = this.getSurface().getModel();
|
|
|
|
// loadSuccess() may have called setAssumeExistence( true );
|
|
ve.init.platform.linkCache.setAssumeExistence( false );
|
|
surfaceModel.connect( this, {
|
|
history: 'updateToolbarSaveButtonState'
|
|
} );
|
|
|
|
// Handle cancel events, i.e. pressing <escape>
|
|
this.getSurface().connect( this, {
|
|
cancel: 'onSurfaceCancel'
|
|
} );
|
|
|
|
// Iterate over the trigger registry and resolve any access key conflicts
|
|
for ( const name in ve.ui.triggerRegistry.registry ) {
|
|
const triggers = ve.ui.triggerRegistry.registry[ name ];
|
|
for ( let i = 0; i < triggers.length; i++ ) {
|
|
if ( ve.compare( triggers[ i ].modifiers, accessKeyModifiers ) ) {
|
|
this.disableAccessKey( triggers[ i ].primary );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( !mw.config.get( 'wgVisualEditorConfig' ).enableHelpCompletion ) {
|
|
this.getSurface().commandRegistry.unregister( 'openHelpCompletions' );
|
|
this.getSurface().commandRegistry.unregister( 'openHelpCompletionsTrigger' );
|
|
}
|
|
|
|
if ( !this.canEdit ) {
|
|
this.getSurface().setReadOnly( true );
|
|
} else {
|
|
// TODO: If the user rejects joining the collab session, start auto-save
|
|
if ( !this.currentUrl.searchParams.has( 'collabSession' ) ) {
|
|
// Auto-save
|
|
this.initAutosave();
|
|
}
|
|
|
|
setTimeout( () => {
|
|
mw.libs.ve.targetSaver.preloadDeflate();
|
|
}, 500 );
|
|
}
|
|
|
|
// Parent method
|
|
ve.init.mw.ArticleTarget.super.prototype.surfaceReady.apply( this, arguments );
|
|
|
|
mw.hook( 've.activationComplete' ).fire();
|
|
};
|
|
|
|
/**
|
|
* Handle surface cancel events
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSurfaceCancel = function () {
|
|
this.tryTeardown( false, 'navigate-read' );
|
|
};
|
|
|
|
/**
|
|
* Runs after the surface has been made ready and visible
|
|
*
|
|
* Implementing sub-classes must call this method.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.afterSurfaceReady = function () {
|
|
this.restoreEditSection();
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.storeDocState = function ( html ) {
|
|
const mode = this.getSurface().getMode();
|
|
this.getSurface().getModel().storeDocState( {
|
|
request: {
|
|
pageName: this.getPageName(),
|
|
mode: mode,
|
|
// Check true section editing is in use
|
|
section: ( mode === 'source' || this.enableVisualSectionEditing ) ? this.section : null
|
|
},
|
|
response: {
|
|
etag: this.etag,
|
|
fromEditedState: this.fromEditedState,
|
|
switched: this.switched,
|
|
preloaded: this.preloaded,
|
|
notices: this.remoteNotices,
|
|
protectedClasses: this.protectedClasses,
|
|
basetimestamp: this.baseTimeStamp,
|
|
starttimestamp: this.startTimeStamp,
|
|
oldid: this.revid,
|
|
canEdit: this.canEdit,
|
|
wouldautocreate: this.wouldautocreate,
|
|
copyrightWarning: this.copyrightWarning,
|
|
checkboxesDef: this.checkboxesDef,
|
|
checkboxesMessages: this.checkboxesMessages
|
|
}
|
|
}, html );
|
|
};
|
|
|
|
/**
|
|
* Disable an access key by removing the attribute from any element containing it
|
|
*
|
|
* @param {string} key Access key
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.disableAccessKey = function ( key ) {
|
|
$( '[accesskey=' + key + ']' ).each( ( i, el ) => {
|
|
const $el = $( el );
|
|
|
|
$el
|
|
.attr( 'data-old-accesskey', $el.attr( 'accesskey' ) )
|
|
.removeAttr( 'accesskey' );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Re-enable all access keys
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.restoreAccessKeys = function () {
|
|
$( '[data-old-accesskey]' ).each( ( i, el ) => {
|
|
const $el = $( el );
|
|
|
|
$el
|
|
.attr( 'accesskey', $el.attr( 'data-old-accesskey' ) )
|
|
.removeAttr( 'data-old-accesskey' );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Handle an unsuccessful load request.
|
|
*
|
|
* This method is called within the context of a target instance.
|
|
*
|
|
* @param {string} code Error code from mw.Api
|
|
* @param {Object} errorDetails API response
|
|
* @fires ve.init.mw.ArticleTarget#loadError
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.loadFail = function () {
|
|
this.loading = null;
|
|
this.emit( 'loadError' );
|
|
};
|
|
|
|
/**
|
|
* Replace the page content with new HTML.
|
|
*
|
|
* @method
|
|
* @param {string} html Rendered HTML from server
|
|
* @param {string} categoriesHtml Rendered categories HTML from server
|
|
* @param {string} displayTitle 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.
|
|
* @param {string} contentSub HTML to show as the content subtitle
|
|
* @param {Array} sections Section data to display in the TOC
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.replacePageContent = function (
|
|
html, categoriesHtml, displayTitle, lastModified, contentSub, sections
|
|
) {
|
|
// eslint-disable-next-line no-jquery/no-append-html
|
|
this.$editableContent.find( '.mw-parser-output' ).first().replaceWith( html );
|
|
mw.hook( 'wikipage.content' ).fire( this.$editableContent );
|
|
|
|
if ( displayTitle ) {
|
|
// eslint-disable-next-line no-jquery/no-html
|
|
$( '#firstHeading' ).html( displayTitle );
|
|
}
|
|
|
|
// Categories are only shown in AMC on mobile
|
|
if ( $( '#catlinks' ).length ) {
|
|
const $categories = $( $.parseHTML( categoriesHtml ) );
|
|
mw.hook( 'wikipage.categories' ).fire( $categories );
|
|
$( '#catlinks' ).replaceWith( $categories );
|
|
}
|
|
|
|
mw.util.clearSubtitle();
|
|
mw.util.addSubtitle( contentSub );
|
|
|
|
this.setRealRedirectInterface();
|
|
|
|
mw.hook( 'wikipage.tableOfContents' ).fire( sections );
|
|
};
|
|
|
|
/**
|
|
* Handle successful DOM save event.
|
|
*
|
|
* @param {Object} data Save data from the API
|
|
* @param {boolean} data.nocontent Indicates that page HTML and related properties were omitted
|
|
* @param {string} data.content Rendered page HTML from server
|
|
* @param {string} data.categorieshtml Rendered categories HTML from server
|
|
* @param {number} data.newrevid New revision id, undefined if unchanged
|
|
* @param {boolean} data.isRedirect Whether this page is a redirect or not
|
|
* @param {string} data.displayTitleHtml What HTML to show as the page title
|
|
* @param {Object} data.lastModified Object containing user-formatted date
|
|
* and time strings, or undefined if we made no change.
|
|
* @param {string} data.contentSub HTML to show as the content subtitle
|
|
* @param {Array} data.modules The modules to be loaded on the page
|
|
* @param {Object} data.jsconfigvars The mw.config values needed on the page
|
|
* @param {Array} data.sections Section data to display in the TOC
|
|
* @param {boolean} data.tempusercreated True if we just became logged in as a temporary user
|
|
* @param {string} data.tempusercreatedredirect URL to visit to finish creating temp account
|
|
* @fires ve.init.mw.ArticleTarget#save
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveComplete = function ( data ) {
|
|
this.editSummaryValue = null;
|
|
this.initialEditSummary = null;
|
|
|
|
this.saveDeferred.resolve();
|
|
this.emit( 'save', data );
|
|
|
|
// This is a page creation, a restoration, or we loaded the editor from a non-view page,
|
|
// or we just became logged in as a temporary user: refresh the page.
|
|
if ( data.nocontent || data.tempusercreated ) {
|
|
// Teardown the target, ensuring auto-save data is cleared
|
|
this.teardown().then( () => {
|
|
if ( data.newrevid !== undefined ) {
|
|
let action;
|
|
if ( this.restoring ) {
|
|
action = 'restored';
|
|
} else if ( !this.pageExists ) {
|
|
action = 'created';
|
|
} else {
|
|
action = 'saved';
|
|
}
|
|
require( 'mediawiki.action.view.postEdit' ).fireHookOnPageReload( action, data.tempusercreated );
|
|
}
|
|
|
|
if ( data.tempusercreatedredirect ) {
|
|
location.href = data.tempusercreatedredirect;
|
|
} else {
|
|
const newUrl = new URL( this.viewUrl );
|
|
if ( data.newrevid !== undefined ) {
|
|
// For GrowthExperiments
|
|
newUrl.searchParams.set( 'venotify', 'saved' );
|
|
}
|
|
if ( data.isRedirect ) {
|
|
newUrl.searchParams.set( 'redirect', 'no' );
|
|
}
|
|
location.href = newUrl;
|
|
}
|
|
} );
|
|
} else {
|
|
// Update watch link to match 'watch checkbox' in save dialog.
|
|
// User logged in if module loaded.
|
|
if ( mw.loader.getState( 'mediawiki.page.watch.ajax' ) === 'ready' ) {
|
|
const watch = require( 'mediawiki.page.watch.ajax' );
|
|
|
|
watch.updatePageWatchStatus(
|
|
data.watched,
|
|
data.watchlistexpiry
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
// If there is an ?oldid= parameter in the URL, this will cause restorePage() to remove it.
|
|
this.restoring = false;
|
|
|
|
// Clear requestedRevId in case it was set by a retry or something; after saving
|
|
// we don't want to go back into oldid mode anyway
|
|
this.requestedRevId = undefined;
|
|
|
|
if ( data.newrevid !== undefined ) {
|
|
mw.config.set( {
|
|
wgCurRevisionId: data.newrevid,
|
|
wgRevisionId: data.newrevid
|
|
} );
|
|
this.revid = data.newrevid;
|
|
this.currentRevisionId = data.newrevid;
|
|
}
|
|
|
|
// Update module JS config values and notify ResourceLoader of any new
|
|
// modules needed to be added to the page
|
|
mw.config.set( data.jsconfigvars );
|
|
mw.loader.load( data.modules );
|
|
|
|
mw.config.set( {
|
|
wgIsRedirect: !!data.isRedirect
|
|
} );
|
|
|
|
if ( this.saveDialog ) {
|
|
this.saveDialog.reset();
|
|
}
|
|
|
|
this.replacePageContent(
|
|
data.content,
|
|
data.categorieshtml,
|
|
data.displayTitleHtml,
|
|
data.lastModified,
|
|
data.contentSub,
|
|
data.sections
|
|
);
|
|
|
|
// Tear down the target now that we're done saving
|
|
// Not passing trackMechanism because this isn't an abort action
|
|
this.tryTeardown( true );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle an unsuccessful save request.
|
|
*
|
|
* @param {HTMLDocument} doc HTML document we tried to save
|
|
* @param {Object} saveData Options that were used
|
|
* @param {string} code Error code
|
|
* @param {Object|null} data Full API response data, or XHR error details
|
|
* @fires ve.init.mw.ArticleTarget#saveError
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveFail = function ( doc, saveData, code, data ) {
|
|
this.pageDeletedWarning = false;
|
|
|
|
let handled = false;
|
|
// Handle empty response
|
|
if ( !data ) {
|
|
this.saveErrorEmpty();
|
|
handled = true;
|
|
}
|
|
|
|
if ( !handled && data.errors ) {
|
|
for ( let i = 0; i < data.errors.length; i++ ) {
|
|
const error = data.errors[ i ];
|
|
|
|
if ( error.code === 'assertanonfailed' || error.code === 'assertuserfailed' || error.code === 'assertnameduserfailed' ) {
|
|
this.refreshUser().then( ( username ) => {
|
|
this.saveErrorNewUser( username );
|
|
}, () => {
|
|
this.saveErrorUnknown( data );
|
|
} );
|
|
handled = true;
|
|
} else if ( error.code === 'editconflict' ) {
|
|
this.editConflict();
|
|
handled = true;
|
|
} else if ( error.code === 'pagedeleted' ) {
|
|
this.saveErrorPageDeleted();
|
|
handled = true;
|
|
} else if ( error.code === 'hookaborted' ) {
|
|
this.saveErrorHookAborted( data );
|
|
handled = true;
|
|
} else if ( error.code === 'readonly' ) {
|
|
this.saveErrorReadOnly( data );
|
|
handled = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( !handled ) {
|
|
const saveErrorHandlerFactory = ve.init.mw.saveErrorHandlerFactory;
|
|
for ( const name in saveErrorHandlerFactory.registry ) {
|
|
const handler = saveErrorHandlerFactory.lookup( name );
|
|
if ( handler.static.matchFunction( data ) ) {
|
|
handler.static.process( data, this );
|
|
handled = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle (other) unknown and/or unrecoverable errors
|
|
if ( !handled ) {
|
|
this.saveErrorUnknown( data );
|
|
handled = true;
|
|
}
|
|
|
|
let errorCodes;
|
|
if ( data.errors ) {
|
|
errorCodes = OO.unique( data.errors.map( ( err ) => err.code ) ).join( ',' );
|
|
} else if ( ve.getProp( data, 'visualeditoredit', 'edit', 'captcha' ) ) {
|
|
// Eww
|
|
errorCodes = 'captcha';
|
|
} else {
|
|
errorCodes = 'http-' + ( ( data.xhr && data.xhr.status ) || 0 );
|
|
}
|
|
this.emit( 'saveError', errorCodes );
|
|
};
|
|
|
|
/**
|
|
* Show an save process error message
|
|
*
|
|
* @param {string|jQuery|Node[]} msg Message content (string of HTML, jQuery object or array of
|
|
* Node objects)
|
|
* @param {boolean} [warning=false] Whether or not this is a warning.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.showSaveError = function ( msg, warning ) {
|
|
this.saveDeferred.reject( [ new OO.ui.Error( msg, { warning: warning } ) ] );
|
|
};
|
|
|
|
/**
|
|
* Extract the error messages from an erroneous API response
|
|
*
|
|
* @param {Object} data API response data
|
|
* @return {jQuery}
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.extractErrorMessages = function ( data ) {
|
|
const $errorMsgs = ( new mw.Api() ).getErrorMessage( data );
|
|
// Warning, this assumes there are only Element nodes in the jQuery set
|
|
$errorMsgs.toArray().forEach( ve.targetLinksToNewWindow );
|
|
return $errorMsgs;
|
|
};
|
|
|
|
/**
|
|
* Handle general save error
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorEmpty = function () {
|
|
this.showSaveError( this.extractErrorMessages( null ) );
|
|
};
|
|
|
|
/**
|
|
* Handle hook abort save error
|
|
*
|
|
* @param {Object} data API response data
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorHookAborted = function ( data ) {
|
|
this.showSaveError( this.extractErrorMessages( data ) );
|
|
};
|
|
|
|
/**
|
|
* Handle assert error indicating another user is logged in.
|
|
*
|
|
* @param {string|null} username Name of newly logged-in user, or a temporary account name,
|
|
* or null if logged-out and temporary accounts are disabled
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorNewUser = function ( username ) {
|
|
const $msg = mw.message(
|
|
username === null ?
|
|
'visualeditor-savedialog-identify-anon' :
|
|
mw.util.isTemporaryUser( username ) ?
|
|
'visualeditor-savedialog-identify-temp' :
|
|
'visualeditor-savedialog-identify-user',
|
|
username
|
|
).parseDom();
|
|
|
|
this.showSaveError( $msg, true );
|
|
};
|
|
|
|
/**
|
|
* Handle unknown save error
|
|
*
|
|
* @param {Object|null} data API response data
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorUnknown = function ( data ) {
|
|
this.showSaveError( this.extractErrorMessages( data ) );
|
|
};
|
|
|
|
/**
|
|
* Handle page deleted error
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorPageDeleted = function () {
|
|
this.pageDeletedWarning = true;
|
|
// The API error message 'apierror-pagedeleted' is poor, make our own
|
|
this.showSaveError( mw.msg( 'visualeditor-recreate', mw.msg( 'ooui-dialog-process-continue' ) ), true );
|
|
};
|
|
|
|
/**
|
|
* Handle read only error
|
|
*
|
|
* @param {Object} data API response data
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.saveErrorReadOnly = function ( data ) {
|
|
this.showSaveError( this.extractErrorMessages( data ), true );
|
|
};
|
|
|
|
/**
|
|
* Handle an edit conflict
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.editConflict = function () {
|
|
this.saveDialog.popPending();
|
|
this.saveDialog.swapPanel( 'conflict' );
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the review button in the save dialog.
|
|
*
|
|
* @fires ve.init.mw.ArticleTarget#saveReview
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogReview = function () {
|
|
if ( !this.saveDialog.hasDiff ) {
|
|
this.emit( 'saveReview' );
|
|
this.saveDialog.pushPending();
|
|
// Acquire a temporary user username before diffing, so that signatures and
|
|
// user-related magic words display the temp user instead of IP user in the diff. (T331397)
|
|
mw.user.acquireTempUserName().then( () => {
|
|
if ( this.pageExists ) {
|
|
// Has no callback, handled via this.showChangesDiff
|
|
this.showChanges( this.getDocToSave() );
|
|
} else {
|
|
this.serialize( this.getDocToSave() ).then( ( data ) => {
|
|
this.onSaveDialogReviewComplete( data.content );
|
|
} );
|
|
}
|
|
} );
|
|
} else {
|
|
this.saveDialog.swapPanel( 'review' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the show preview button in the save dialog.
|
|
*
|
|
* @fires ve.init.mw.ArticleTarget#savePreview
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogPreview = function () {
|
|
const api = this.getContentApi();
|
|
|
|
if ( !this.saveDialog.$previewViewer.children().length ) {
|
|
this.emit( 'savePreview' );
|
|
this.saveDialog.pushPending();
|
|
|
|
const params = {};
|
|
|
|
const sectionTitle = this.sectionTitle && this.sectionTitle.getValue();
|
|
if ( sectionTitle ) {
|
|
params.section = 'new';
|
|
params.sectiontitle = sectionTitle;
|
|
}
|
|
if ( mw.config.get( 'wgUserVariant' ) ) {
|
|
params.variant = mw.config.get( 'wgUserVariant' );
|
|
}
|
|
|
|
// Acquire a temporary user username before previewing, so that signatures and
|
|
// user-related magic words display the temp user instead of IP user in the preview. (T331397)
|
|
mw.user.acquireTempUserName().then( () => api.post( ve.extendObject( params, {
|
|
action: 'parse',
|
|
title: this.getPageName(),
|
|
text: this.getDocToSave(),
|
|
pst: true,
|
|
preview: true,
|
|
sectionpreview: this.section !== null,
|
|
disableeditsection: true,
|
|
uselang: mw.config.get( 'wgUserLanguage' ),
|
|
useskin: mw.config.get( 'skin' ),
|
|
mobileformat: OO.ui.isMobile(),
|
|
prop: [ 'text', 'categorieshtml', 'displaytitle', 'subtitle', 'modules', 'jsconfigvars' ]
|
|
} ) ) ).then( ( response ) => {
|
|
this.saveDialog.showPreview( response );
|
|
}, ( errorCode, details ) => {
|
|
this.saveDialog.showPreview( this.extractErrorMessages( details ) );
|
|
} ).always( () => {
|
|
this.bindSaveDialogClearDiff();
|
|
} );
|
|
} else {
|
|
this.saveDialog.swapPanel( 'preview' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Clear the save dialog's diff cache when the document changes
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.bindSaveDialogClearDiff = function () {
|
|
// Invalidate the viewer wikitext on next change
|
|
this.getSurface().getModel().getDocument().once( 'transact',
|
|
this.saveDialog.clearDiff.bind( this.saveDialog )
|
|
);
|
|
if ( this.sectionTitle ) {
|
|
this.sectionTitle.once( 'change', this.saveDialog.clearDiff.bind( this.saveDialog ) );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle completed serialize request for diff views for new page creations.
|
|
*
|
|
* @param {string} wikitext
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogReviewComplete = function ( wikitext ) {
|
|
this.bindSaveDialogClearDiff();
|
|
this.saveDialog.setDiffAndReview(
|
|
ve.createDeferred().resolve( $( '<pre>' ).text( wikitext ) ).promise(),
|
|
this.getVisualDiffGeneratorPromise(),
|
|
this.getSurface().getModel().getDocument().getHtmlDocument()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Get a visual diff object for the current document state
|
|
*
|
|
* @return {jQuery.Promise} Promise resolving with a generator for a ve.dm.VisualDiff visual diff
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getVisualDiffGeneratorPromise = function () {
|
|
return mw.loader.using( 'ext.visualEditor.diffLoader' ).then( () => {
|
|
const mode = this.getSurface().getMode();
|
|
|
|
if ( !this.originalDmDocPromise ) {
|
|
if ( mode === 'source' ) {
|
|
// Always load full doc in source mode for correct reference diffing (T260008)
|
|
this.originalDmDocPromise = mw.libs.ve.diffLoader.fetchRevision( this.revid, this.getPageName() );
|
|
} else {
|
|
if ( !this.fromEditedState ) {
|
|
const dmDoc = this.constructor.static.createModelFromDom( this.doc, 'visual' );
|
|
let dmDocOrNode;
|
|
if ( this.section !== null && this.enableVisualSectionEditing ) {
|
|
dmDocOrNode = dmDoc.getNodesByType( 'section' )[ 0 ];
|
|
} else {
|
|
dmDocOrNode = dmDoc;
|
|
}
|
|
this.originalDmDocPromise = ve.createDeferred().resolve( dmDocOrNode ).promise();
|
|
} else {
|
|
this.originalDmDocPromise = mw.libs.ve.diffLoader.fetchRevision( this.revid, this.getPageName(), this.section );
|
|
}
|
|
}
|
|
}
|
|
|
|
if ( mode === 'source' ) {
|
|
// Acquire a temporary user username before diffing, so that signatures and
|
|
// user-related magic words display the temp user instead of IP user in the diff. (T331397)
|
|
const newRevPromise = mw.user.acquireTempUserName().then( () => this.getContentApi().post( {
|
|
action: 'visualeditor',
|
|
paction: 'parse',
|
|
page: this.getPageName(),
|
|
wikitext: this.getSurface().getDom(),
|
|
section: this.section,
|
|
stash: 0,
|
|
pst: true
|
|
} ) ).then(
|
|
// Source mode always fetches the whole document, so set section=null to unwrap sections
|
|
( response ) => mw.libs.ve.diffLoader.getModelFromResponse( response, null )
|
|
);
|
|
|
|
return mw.libs.ve.diffLoader.getVisualDiffGeneratorPromise( this.originalDmDocPromise, newRevPromise );
|
|
} else {
|
|
return this.originalDmDocPromise.then(
|
|
( originalDmDoc ) => () => new ve.dm.VisualDiff( originalDmDoc, this.getSurface().getModel().getAttachedRoot() )
|
|
);
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Handle clicks on the resolve conflict button in the conflict dialog.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogResolveConflict = function () {
|
|
const fields = { wpSave: 1 };
|
|
|
|
if ( this.getSurface().getMode() === 'source' && this.section !== null ) {
|
|
// TODO: This should happen in #getSaveFields, check if moving it there breaks anything
|
|
fields.section = this.section;
|
|
}
|
|
// Get Wikitext from the DOM, and set up a submit call when it's done
|
|
this.serialize( this.getDocToSave() ).then( ( data ) => {
|
|
this.submitWithSaveFields( fields, data.content );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Handle dialog retry events
|
|
* So we can handle trying to save again after page deletion warnings
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogRetry = function () {
|
|
if ( this.pageDeletedWarning ) {
|
|
this.recreating = true;
|
|
this.pageExists = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Load the editor.
|
|
*
|
|
* This method initiates an API request for the page data unless dataPromise is passed in,
|
|
* in which case it waits for that promise instead.
|
|
*
|
|
* @param {jQuery.Promise} [dataPromise] Promise for pending request, if any
|
|
* @return {jQuery.Promise} Data promise
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.load = function ( dataPromise ) {
|
|
// Prevent duplicate requests
|
|
if ( this.loading ) {
|
|
return this.loading;
|
|
}
|
|
this.events.trackActivationStart( mw.libs.ve.activationStart );
|
|
mw.libs.ve.activationStart = null;
|
|
|
|
const url = new URL( location.href );
|
|
dataPromise = dataPromise || mw.libs.ve.targetLoader.requestPageData( this.getDefaultMode(), this.getPageName(), {
|
|
sessionStore: true,
|
|
section: this.section,
|
|
oldId: this.requestedRevId,
|
|
targetName: this.constructor.static.trackingName,
|
|
editintro: url.searchParams.get( 'editintro' ),
|
|
preload: url.searchParams.get( 'preload' ),
|
|
preloadparams: mw.util.getArrayParam( 'preloadparams', url.searchParams )
|
|
} );
|
|
|
|
this.loading = dataPromise;
|
|
dataPromise
|
|
.done( this.loadSuccess.bind( this ) )
|
|
.fail( this.loadFail.bind( this ) );
|
|
|
|
return dataPromise;
|
|
};
|
|
|
|
/**
|
|
* Clear the state of this target, preparing it to be reactivated later.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearState = function () {
|
|
this.restoreAccessKeys();
|
|
this.clearPreparedCacheKey();
|
|
this.loading = null;
|
|
this.saving = null;
|
|
this.clearDiff();
|
|
this.serializing = false;
|
|
this.submitting = false;
|
|
this.baseTimeStamp = null;
|
|
this.startTimeStamp = null;
|
|
this.checkboxes = null;
|
|
this.initialSourceRange = null;
|
|
this.doc = null;
|
|
this.originalDmDocPromise = null;
|
|
this.originalHtml = null;
|
|
this.toolbarSaveButton = null;
|
|
this.section = null;
|
|
this.visibleSection = null;
|
|
this.visibleSectionOffset = null;
|
|
this.editNotices = [];
|
|
this.remoteNotices = [];
|
|
this.localNoticeMessages = [];
|
|
this.recovered = false;
|
|
this.teardownPromise = null;
|
|
};
|
|
|
|
/**
|
|
* Switch to edit source mode
|
|
*
|
|
* Opens a confirmation dialog if the document is modified or VE wikitext mode
|
|
* is not available.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.editSource = function () {
|
|
const modified = this.fromEditedState || this.getSurface().getModel().hasBeenModified();
|
|
|
|
this.switchToWikitextEditor( modified );
|
|
};
|
|
|
|
/**
|
|
* Get a document to save, cached until the surface is modified
|
|
*
|
|
* The default implementation returns an HTMLDocument, but other targets
|
|
* may use a different document model (e.g. plain text for source mode).
|
|
*
|
|
* @return {Object} Document to save
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getDocToSave = function () {
|
|
if ( !this.docToSave ) {
|
|
this.docToSave = this.createDocToSave();
|
|
// Cache clearing events
|
|
const surface = this.getSurface();
|
|
surface.getModel().getDocument().once( 'transact', this.clearDocToSave.bind( this ) );
|
|
surface.once( 'destroy', this.clearDocToSave.bind( this ) );
|
|
}
|
|
return this.docToSave;
|
|
};
|
|
|
|
/**
|
|
* Create a document to save
|
|
*
|
|
* @return {Object} Document to save
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.createDocToSave = function () {
|
|
return this.getSurface().getDom();
|
|
};
|
|
|
|
/**
|
|
* Clear the document to save from the cache
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearDocToSave = function () {
|
|
this.docToSave = null;
|
|
this.clearPreparedCacheKey();
|
|
};
|
|
|
|
/**
|
|
* Serialize the current document and store the result in the serialization cache on the server.
|
|
*
|
|
* This function returns a promise that is resolved once serialization is complete, with the
|
|
* cache key passed as the first parameter.
|
|
*
|
|
* If there's already a request pending for the same (reference-identical) HTMLDocument, this
|
|
* function will not initiate a new request but will return the promise for the pending request.
|
|
* If a request for the same document has already been completed, this function will keep returning
|
|
* the same promise (which will already have been resolved) until clearPreparedCacheKey() is called.
|
|
*
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.prepareCacheKey = function ( doc ) {
|
|
const start = ve.now();
|
|
|
|
if ( this.getSurface().getMode() === 'source' ) {
|
|
return;
|
|
}
|
|
|
|
if ( this.preparedCacheKeyPromise && this.preparedCacheKeyPromise.doc === doc ) {
|
|
return;
|
|
}
|
|
this.clearPreparedCacheKey();
|
|
|
|
let xhr;
|
|
let aborted = false;
|
|
this.preparedCacheKeyPromise = mw.libs.ve.targetSaver.deflateDoc( doc, this.doc )
|
|
.then( ( deflatedHtml ) => {
|
|
if ( aborted ) {
|
|
return ve.createDeferred().reject();
|
|
}
|
|
xhr = this.getContentApi().postWithToken( 'csrf',
|
|
{
|
|
action: 'visualeditoredit',
|
|
paction: 'serializeforcache',
|
|
html: deflatedHtml,
|
|
page: this.getPageName(),
|
|
oldid: this.revid,
|
|
etag: this.etag
|
|
},
|
|
{ contentType: 'multipart/form-data' }
|
|
);
|
|
return xhr.then(
|
|
( response ) => {
|
|
const trackData = { duration: ve.now() - start };
|
|
if ( response.visualeditoredit && typeof response.visualeditoredit.cachekey === 'string' ) {
|
|
this.events.track( 'performance.system.serializeforcache', trackData );
|
|
return {
|
|
cacheKey: response.visualeditoredit.cachekey,
|
|
// Pass the HTML for retries.
|
|
html: deflatedHtml
|
|
};
|
|
} else {
|
|
this.events.track( 'performance.system.serializeforcache.nocachekey', trackData );
|
|
return ve.createDeferred().reject();
|
|
}
|
|
},
|
|
() => {
|
|
this.events.track( 'performance.system.serializeforcache.fail', { duration: ve.now() - start } );
|
|
return ve.createDeferred().reject();
|
|
}
|
|
);
|
|
} )
|
|
.promise( {
|
|
abort: () => {
|
|
if ( xhr ) {
|
|
xhr.abort();
|
|
}
|
|
aborted = true;
|
|
},
|
|
doc: doc
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get the prepared wikitext, if any. Same as prepareWikitext() but does not initiate a request
|
|
* if one isn't already pending or finished. Instead, it returns a rejected promise in that case.
|
|
*
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
* @return {jQuery.Promise} Abortable promise, resolved with a plain object containing `cacheKey`,
|
|
* and `html` for retries.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getPreparedCacheKey = function ( doc ) {
|
|
if ( this.preparedCacheKeyPromise && this.preparedCacheKeyPromise.doc === doc ) {
|
|
return this.preparedCacheKeyPromise;
|
|
}
|
|
return ve.createDeferred().reject().promise();
|
|
};
|
|
|
|
/**
|
|
* Clear the promise for the prepared wikitext cache key, and abort it if it's still in progress.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearPreparedCacheKey = function () {
|
|
if ( this.preparedCacheKeyPromise ) {
|
|
this.preparedCacheKeyPromise.abort();
|
|
this.preparedCacheKeyPromise = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Try submitting an API request with a cache key for prepared wikitext, falling back to submitting
|
|
* HTML directly if there is no cache key present or pending, or if the request for the cache key
|
|
* fails, or if using the cache key fails with a badcachekey error.
|
|
*
|
|
* This function will use mw.Api#postWithToken to retry automatically when encountering a 'badtoken'
|
|
* error.
|
|
*
|
|
* @param {HTMLDocument|string} doc Document to submit or string in source mode
|
|
* @param {Object} extraData POST parameters to send. Do not include 'html', 'cachekey' or 'format'.
|
|
* @param {string} [eventName] If set, log an event when the request completes successfully. The
|
|
* full event name used will be 'performance.system.{eventName}.withCacheKey' or .withoutCacheKey
|
|
* depending on whether or not a cache key was used.
|
|
* @return {jQuery.Promise} Promise which resolves/rejects when saving is complete/fails
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.tryWithPreparedCacheKey = function ( doc, extraData, eventName ) {
|
|
if ( this.getSurface().getMode() === 'source' ) {
|
|
const data = ve.copy( extraData );
|
|
|
|
// TODO: This should happen in #getSaveOptions, check if moving it there breaks anything
|
|
if ( this.section !== null ) {
|
|
data.section = this.section;
|
|
}
|
|
if ( this.sectionTitle ) {
|
|
data.sectiontitle = this.sectionTitle.getValue();
|
|
data.summary = undefined;
|
|
}
|
|
|
|
return mw.libs.ve.targetSaver.postWikitext(
|
|
doc,
|
|
data,
|
|
{ api: this.getContentApi() }
|
|
);
|
|
}
|
|
|
|
// getPreparedCacheKey resolves with { cacheKey: ..., html: ... } or rejects.
|
|
// After modification it never rejects, just resolves with { html: ... } instead
|
|
const htmlOrCacheKeyPromise = this.getPreparedCacheKey( doc ).then(
|
|
// Success, use promise as-is.
|
|
null,
|
|
// Fail, get deflatedHtml promise
|
|
() => mw.libs.ve.targetSaver.deflateDoc( doc, this.doc ).then( ( html ) => ( { html: html } ) ) );
|
|
|
|
return htmlOrCacheKeyPromise.then( ( htmlOrCacheKey ) => mw.libs.ve.targetSaver.postHtml(
|
|
htmlOrCacheKey.html,
|
|
htmlOrCacheKey.cacheKey,
|
|
extraData,
|
|
{
|
|
onCacheKeyFail: this.clearPreparedCacheKey.bind( this ),
|
|
api: this.getContentApi(),
|
|
track: this.events.track.bind( this.events ),
|
|
eventName: eventName,
|
|
now: ve.now
|
|
}
|
|
) );
|
|
};
|
|
|
|
/**
|
|
* Handle the save dialog's save event
|
|
*
|
|
* Validates the inputs then starts the save process
|
|
*
|
|
* @param {jQuery.Deferred} saveDeferred Deferred object to resolve/reject when the save
|
|
* succeeds/fails.
|
|
* @fires ve.init.mw.ArticleTarget#saveInitiated
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.onSaveDialogSave = function ( saveDeferred ) {
|
|
if ( this.deactivating ) {
|
|
return;
|
|
}
|
|
|
|
const saveOptions = this.getSaveOptions();
|
|
|
|
if (
|
|
+mw.user.options.get( 'forceeditsummary' ) &&
|
|
( saveOptions.summary === '' || saveOptions.summary === this.initialEditSummary ) &&
|
|
!this.saveDialog.messages.missingsummary
|
|
) {
|
|
this.saveDialog.showMessage(
|
|
'missingsummary',
|
|
new OO.ui.HtmlSnippet( ve.init.platform.getParsedMessage( 'missingsummary' ) )
|
|
);
|
|
this.saveDialog.popPending();
|
|
} else {
|
|
this.emit( 'saveInitiated' );
|
|
this.startSave( saveOptions );
|
|
this.saveDeferred = saveDeferred;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start the save process
|
|
*
|
|
* @param {Object} saveOptions Save options
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.startSave = function ( saveOptions ) {
|
|
this.save( this.getDocToSave(), saveOptions );
|
|
};
|
|
|
|
/**
|
|
* Get save form fields from the save dialog form.
|
|
*
|
|
* @return {Object} Form data for submission to the MediaWiki action=edit UI
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveFields = function () {
|
|
const fields = {};
|
|
|
|
if ( this.section === 'new' ) {
|
|
// MediaWiki action=edit UI doesn't have separate parameters for edit summary and new section
|
|
// title. The edit summary parameter is supposed to contain the section title, and the real
|
|
// summary is autogenerated.
|
|
fields.wpSummary = this.sectionTitle ? this.sectionTitle.getValue() : '';
|
|
} else {
|
|
fields.wpSummary = this.saveDialog ?
|
|
this.saveDialog.editSummaryInput.getValue() :
|
|
( this.editSummaryValue || this.initialEditSummary );
|
|
}
|
|
|
|
let name;
|
|
// Extra save fields added by extensions
|
|
for ( name in this.saveFields ) {
|
|
fields[ name ] = this.saveFields[ name ]();
|
|
}
|
|
|
|
if ( this.recreating ) {
|
|
fields.wpRecreate = true;
|
|
}
|
|
|
|
for ( name in this.checkboxesByName ) {
|
|
// DropdownInputWidget or CheckboxInputWidget
|
|
if ( !this.checkboxesByName[ name ].isSelected || this.checkboxesByName[ name ].isSelected() ) {
|
|
fields[ name ] = this.checkboxesByName[ name ].getValue();
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
};
|
|
|
|
/**
|
|
* Invoke #submit with the data from #getSaveFields
|
|
*
|
|
* @param {Object} fields Fields to add in addition to those from #getSaveFields
|
|
* @param {string} wikitext Wikitext to submit
|
|
* @return {boolean} Whether submission was started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.submitWithSaveFields = function ( fields, wikitext ) {
|
|
return this.submit( wikitext, ve.extendObject( this.getSaveFields(), fields ) );
|
|
};
|
|
|
|
/**
|
|
* Get edit API options from the save dialog form.
|
|
*
|
|
* @return {Object} Save options for submission to the MediaWiki API
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveOptions = function () {
|
|
const options = this.getSaveFields(),
|
|
fieldMap = {
|
|
wpSummary: 'summary',
|
|
wpMinoredit: 'minor',
|
|
wpWatchthis: 'watchlist',
|
|
wpCaptchaId: 'captchaid',
|
|
wpCaptchaWord: 'captchaword'
|
|
};
|
|
|
|
for ( const key in fieldMap ) {
|
|
if ( options[ key ] !== undefined ) {
|
|
options[ fieldMap[ key ] ] = options[ key ];
|
|
delete options[ key ];
|
|
}
|
|
}
|
|
|
|
options.watchlist = 'watchlist' in options ? 'watch' : 'unwatch';
|
|
|
|
return options;
|
|
};
|
|
|
|
/**
|
|
* Post DOM data to the Parsoid API.
|
|
*
|
|
* This method performs an asynchronous action and uses a callback function to handle the result.
|
|
*
|
|
* this.save( dom, { summary: 'test', minor: true, watch: false } );
|
|
*
|
|
* @param {HTMLDocument} doc Document to save
|
|
* @param {Object} options Saving options. All keys are passed through, including unrecognized ones.
|
|
* - {string} summary Edit summary
|
|
* - {boolean} minor Edit is a minor edit
|
|
* - {boolean} watch Watch the page
|
|
* @return {jQuery.Promise} Save promise, see mw.libs.ve.targetSaver.postHtml
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.save = function ( doc, options ) {
|
|
// Prevent duplicate requests
|
|
if ( this.saving ) {
|
|
return this.saving;
|
|
}
|
|
|
|
const data = ve.extendObject( {}, options, {
|
|
page: this.getPageName(),
|
|
oldid: this.revid,
|
|
basetimestamp: this.baseTimeStamp,
|
|
starttimestamp: this.startTimeStamp,
|
|
etag: this.etag,
|
|
assert: mw.user.isAnon() ? 'anon' : 'user',
|
|
assertuser: mw.user.getName() || undefined
|
|
} );
|
|
|
|
if ( !this.pageExists || this.restoring || !this.isViewPage ) {
|
|
// This is a page creation, a restoration, or we loaded the editor from a non-view page.
|
|
// We can't update the interface to reflect this new state, so we're going to reload the whole page.
|
|
// Therefore we don't need the new revision's HTML content in the API response.
|
|
data.nocontent = true;
|
|
}
|
|
|
|
if ( this.wouldautocreate ) {
|
|
// This means that we might need to redirect to an opaque URL,
|
|
// so we must set up query parameters we want ahead of time.
|
|
// TODO: `this.isRedirect` is only set in visual mode, not in source mode
|
|
data.returntoquery = this.isRedirect ? 'redirect=no' : '';
|
|
data.returntoanchor = this.getSectionHashFromPage();
|
|
}
|
|
|
|
const config = mw.config.get( 'wgVisualEditorConfig' );
|
|
|
|
const taglist = data.vetags ? data.vetags.split( ',' ) : [];
|
|
|
|
if ( config.useChangeTagging ) {
|
|
taglist.push(
|
|
this.getSurface().getMode() === 'source' ? 'visualeditor-wikitext' : 'visualeditor'
|
|
);
|
|
}
|
|
|
|
if (
|
|
this.getSurface().getMode() === 'visual' &&
|
|
mw.config.get( 'wgVisualEditorConfig' ).editCheckTagging
|
|
) {
|
|
const documentModel = this.getSurface().getModel().getDocument();
|
|
// New content needing a reference
|
|
if ( mw.editcheck.findAddedContentNeedingReference( documentModel ).length ) {
|
|
taglist.push( 'editcheck-references' );
|
|
}
|
|
// New content, regardless of if it needs a reference
|
|
if ( mw.editcheck.findAddedContentNeedingReference( documentModel, true ).length ) {
|
|
taglist.push( 'editcheck-newcontent' );
|
|
}
|
|
// Rejection reasons for references
|
|
const rejections = mw.editcheck.getRejectionReasons();
|
|
if ( rejections.length > 0 ) {
|
|
rejections.forEach( ( reason ) => {
|
|
taglist.push( 'editcheck-reference-decline-' + reason );
|
|
} );
|
|
}
|
|
}
|
|
|
|
data.vetags = taglist.join( ',' );
|
|
|
|
const promise = this.saving = this.tryWithPreparedCacheKey( doc, data, 'save' )
|
|
.done( this.saveComplete.bind( this ) )
|
|
.fail( this.saveFail.bind( this, doc, data ) )
|
|
.always( () => {
|
|
this.saving = null;
|
|
} );
|
|
|
|
return promise;
|
|
};
|
|
|
|
/**
|
|
* Show changes in the save dialog
|
|
*
|
|
* @param {Object} doc Document
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.showChanges = function ( doc ) {
|
|
// Invalidate the viewer diff on next change
|
|
this.getSurface().getModel().getDocument().once( 'transact', () => {
|
|
this.clearDiff();
|
|
} );
|
|
this.saveDialog.setDiffAndReview(
|
|
this.getWikitextDiffPromise( doc ),
|
|
this.getVisualDiffGeneratorPromise(),
|
|
this.getSurface().getModel().getDocument().getHtmlDocument()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Clear all state associated with the diff
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.clearDiff = function () {
|
|
if ( this.saveDialog ) {
|
|
this.saveDialog.clearDiff();
|
|
}
|
|
this.wikitextDiffPromise = null;
|
|
};
|
|
|
|
/**
|
|
* Post DOM data to the Parsoid API to retrieve wikitext diff.
|
|
*
|
|
* @param {HTMLDocument} doc Document to compare against (via wikitext)
|
|
* @return {jQuery.Promise} Promise which resolves with the wikitext diff, or rejects with an error
|
|
* @fires ve.init.mw.ArticleTarget#showChanges
|
|
* @fires ve.init.mw.ArticleTarget#showChangesError
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getWikitextDiffPromise = function ( doc ) {
|
|
if ( !this.wikitextDiffPromise ) {
|
|
this.wikitextDiffPromise = this.tryWithPreparedCacheKey( doc, {
|
|
paction: 'diff',
|
|
page: this.getPageName(),
|
|
oldid: this.revid,
|
|
etag: this.etag
|
|
}, 'diff' ).then( ( data ) => {
|
|
if ( !data.diff ) {
|
|
this.emit( 'noChanges' );
|
|
}
|
|
return data.diff;
|
|
} );
|
|
this.wikitextDiffPromise
|
|
.done( this.emit.bind( this, 'showChanges' ) )
|
|
.fail( this.emit.bind( this, 'showChangesError' ) );
|
|
}
|
|
return this.wikitextDiffPromise;
|
|
};
|
|
|
|
/**
|
|
* Post wikitext to MediaWiki.
|
|
*
|
|
* This method performs a synchronous action and will take the user to a new page when complete.
|
|
*
|
|
* this.submit( wikitext, { wpSummary: 'test', wpMinorEdit: 1, wpSave: 1 } );
|
|
*
|
|
* @param {string} wikitext Wikitext to submit
|
|
* @param {Object} fields Other form fields to add (e.g. wpSummary, wpWatchthis, etc.). To actually
|
|
* save the wikitext, add { wpSave: 1 }. To go to the diff view, add { wpDiff: 1 }.
|
|
* @return {boolean} Submitting has been started
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.submit = function ( wikitext, fields ) {
|
|
// Prevent duplicate requests
|
|
if ( this.submitting ) {
|
|
return false;
|
|
}
|
|
// Clear autosave now that we don't expect to need it again.
|
|
// FIXME: This isn't transactional, so if the save fails we're left with no recourse.
|
|
this.clearDocState();
|
|
// Save DOM
|
|
this.submitting = true;
|
|
const $form = $( '<form>' ).attr( { method: 'post', enctype: 'multipart/form-data' } ).addClass( 'oo-ui-element-hidden' );
|
|
const params = ve.extendObject( {
|
|
format: 'text/x-wiki',
|
|
model: 'wikitext',
|
|
oldid: this.requestedRevId,
|
|
wpStarttime: this.startTimeStamp,
|
|
wpEdittime: this.baseTimeStamp,
|
|
wpTextbox1: wikitext,
|
|
wpEditToken: mw.user.tokens.get( 'csrfToken' ),
|
|
// MediaWiki function-verification parameters, mostly relevant to the
|
|
// classic editpage, but still required here:
|
|
wpUnicodeCheck: 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ',
|
|
wpUltimateParam: true
|
|
}, fields );
|
|
// Add params as hidden fields
|
|
for ( const key in params ) {
|
|
$form.append( $( '<input>' ).attr( { type: 'hidden', name: key, value: params[ key ] } ) );
|
|
}
|
|
// Submit the form, mimicking a traditional edit
|
|
// Firefox requires the form to be attached
|
|
const submitUrl = mw.util.getUrl( this.getPageName(), {
|
|
action: 'submit',
|
|
veswitched: '1'
|
|
} );
|
|
$form.attr( 'action', submitUrl ).appendTo( 'body' ).trigger( 'submit' );
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Get Wikitext data from the Parsoid API.
|
|
*
|
|
* This method performs an asynchronous action and uses a callback function to handle the result.
|
|
*
|
|
* this.serialize( doc ).then( ( data ) => {
|
|
* // Do something with data.content (wikitext)
|
|
* } );
|
|
*
|
|
* @param {HTMLDocument} doc Document to serialize
|
|
* @param {Function} [callback] Optional callback to run after.
|
|
* Deprecated in favor of using the returned promise.
|
|
* @return {jQuery.Promise} Serialize promise, see mw.libs.ve.targetSaver.postHtml
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.serialize = function ( doc, callback ) {
|
|
// Prevent duplicate requests
|
|
if ( this.serializing ) {
|
|
return this.serializing;
|
|
}
|
|
const promise = this.serializing = this.tryWithPreparedCacheKey( doc, {
|
|
paction: 'serialize',
|
|
page: this.getPageName(),
|
|
oldid: this.revid,
|
|
etag: this.etag
|
|
}, 'serialize' )
|
|
.done( this.emit.bind( this, 'serializeComplete' ) )
|
|
.fail( this.emit.bind( this, 'serializeError' ) )
|
|
.always( () => {
|
|
this.serializing = null;
|
|
} );
|
|
|
|
if ( callback ) {
|
|
OO.ui.warnDeprecation( 'Passing a callback to ve.init.mw.ArticleTarget#serialize is deprecated. Use the returned promise instead.' );
|
|
promise.then( ( data ) => {
|
|
callback.call( this, data.content );
|
|
} );
|
|
}
|
|
|
|
return promise;
|
|
};
|
|
|
|
/**
|
|
* Get list of edit notices.
|
|
*
|
|
* @return {Array} List of edit notices
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getEditNotices = function () {
|
|
return this.editNotices;
|
|
};
|
|
|
|
// FIXME: split out view specific functionality, emit to subclass
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.track = function ( name ) {
|
|
const mode = this.surface ? this.surface.getMode() : this.getDefaultMode();
|
|
ve.track( name, { mode: mode } );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.createSurface = function ( dmDoc, config ) {
|
|
const sections = dmDoc.getNodesByType( 'section' );
|
|
let attachedRoot;
|
|
if ( sections.length && sections.length === 1 ) {
|
|
attachedRoot = sections[ 0 ];
|
|
if ( !attachedRoot.isSurfaceable() ) {
|
|
throw new Error( 'Not a surfaceable node' );
|
|
}
|
|
}
|
|
|
|
// Parent method
|
|
const surface = ve.init.mw.ArticleTarget.super.prototype.createSurface.call(
|
|
this,
|
|
dmDoc,
|
|
ve.extendObject( { attachedRoot: attachedRoot }, config )
|
|
);
|
|
|
|
return surface;
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSurfaceClasses = function () {
|
|
const classes = ve.init.mw.ArticleTarget.super.prototype.getSurfaceClasses.call( this );
|
|
return [ ...classes, 'mw-body-content' ];
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSurfaceConfig = function ( config ) {
|
|
return ve.init.mw.ArticleTarget.super.prototype.getSurfaceConfig.call( this, ve.extendObject( {
|
|
// Don't null selection on blur when editing a document.
|
|
// Do use it in new section mode as there are multiple inputs
|
|
// on the surface (header+content).
|
|
nullSelectionOnBlur: this.section === 'new',
|
|
classes: this.getSurfaceClasses()
|
|
// The following classes are used here:
|
|
// * mw-textarea-proteced
|
|
// * mw-textarea-cproteced
|
|
// * mw-textarea-sproteced
|
|
.concat( this.protectedClasses )
|
|
// addClass doesn't like empty strings
|
|
.filter( ( c ) => c )
|
|
}, config ) );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.teardown = function () {
|
|
if ( !this.teardownPromise ) {
|
|
const surface = this.getSurface();
|
|
|
|
// Restore access keys
|
|
if ( this.$saveAccessKeyElements ) {
|
|
this.$saveAccessKeyElements.attr( 'accesskey', ve.msg( 'accesskey-save' ) );
|
|
this.$saveAccessKeyElements = null;
|
|
}
|
|
if ( surface ) {
|
|
// Disconnect history listener
|
|
surface.getModel().disconnect( this );
|
|
}
|
|
|
|
let saveDialogPromise = ve.createDeferred().resolve().promise();
|
|
if ( this.saveDialog ) {
|
|
if ( this.saveDialog.isOpened() ) {
|
|
// If the save dialog is still open (from saving) close it
|
|
saveDialogPromise = this.saveDialog.close().closed;
|
|
}
|
|
// Release the reference
|
|
this.saveDialog = null;
|
|
}
|
|
|
|
// Parent method
|
|
this.teardownPromise = ve.init.mw.ArticleTarget.super.prototype.teardown.call( this ).then( () => saveDialogPromise.then( () => {
|
|
mw.hook( 've.deactivationComplete' ).fire( this.edited );
|
|
} ) );
|
|
}
|
|
return this.teardownPromise;
|
|
};
|
|
|
|
/**
|
|
* Try to tear down the target, but leave ready for re-activation later
|
|
*
|
|
* Will first prompt the user if required, then call #teardown.
|
|
*
|
|
* @param {boolean} [noPrompt] Do not display a prompt to the user
|
|
* @param {string} [trackMechanism] Abort mechanism; used for event tracking if present
|
|
* @return {jQuery.Promise} Promise which resolves when the target has been torn down, rejects if the target won't be torn down
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.tryTeardown = function ( noPrompt, trackMechanism ) {
|
|
if ( !noPrompt && this.edited && mw.user.options.get( 'useeditwarning' ) ) {
|
|
return this.getSurface().dialogs.openWindow( 'abandonedit' )
|
|
.closed.then( ( data ) => {
|
|
if ( data && data.action === 'discard' ) {
|
|
return this.teardown( trackMechanism );
|
|
}
|
|
return ve.createDeferred().reject().promise();
|
|
} );
|
|
} else {
|
|
return this.teardown( trackMechanism );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setupToolbar = function () {
|
|
// Parent method
|
|
ve.init.mw.ArticleTarget.super.prototype.setupToolbar.apply( this, arguments );
|
|
|
|
this.setupToolbarSaveButton();
|
|
this.updateToolbarSaveButtonState();
|
|
|
|
if ( this.saveDialog ) {
|
|
this.editSummaryValue = this.saveDialog.editSummaryInput.getValue();
|
|
this.saveDialog.disconnect( this );
|
|
this.saveDialog = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Getting the message for the toolbar / save dialog save / publish button
|
|
*
|
|
* @param {boolean} [startProcess] Use version of the label for starting that process, i.e. with an ellipsis after it
|
|
* @param {boolean} [forceShort] Force the short version of the label, always used on mobile
|
|
* @return {Function|string} An i18n message or resolveable function
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveButtonLabel = function ( startProcess, forceShort ) {
|
|
const suffix = startProcess ? '-start' : '';
|
|
|
|
if ( forceShort || OO.ui.isMobile() ) {
|
|
// The following messages can be used here:
|
|
// * visualeditor-savedialog-label-publish-short
|
|
// * visualeditor-savedialog-label-publish-short-start
|
|
// * visualeditor-savedialog-label-save-short
|
|
// * visualeditor-savedialog-label-save-short-start
|
|
if ( mw.config.get( 'wgEditSubmitButtonLabelPublish' ) ) {
|
|
return OO.ui.deferMsg( 'visualeditor-savedialog-label-publish-short' + suffix );
|
|
}
|
|
|
|
return OO.ui.deferMsg( 'visualeditor-savedialog-label-save-short' + suffix );
|
|
}
|
|
|
|
// The following messages can be used here
|
|
// * publishpage
|
|
// * publishpage-start
|
|
// * publishchanges
|
|
// * publishchanges-start
|
|
// * savearticle
|
|
// * savearticle-start
|
|
// * savechanges
|
|
// * savechanges-start
|
|
if ( mw.config.get( 'wgEditSubmitButtonLabelPublish' ) ) {
|
|
return OO.ui.deferMsg( ( !this.pageExists ? 'publishpage' : 'publishchanges' ) + suffix );
|
|
}
|
|
|
|
return OO.ui.deferMsg( ( !this.pageExists ? 'savearticle' : 'savechanges' ) + suffix );
|
|
};
|
|
|
|
/**
|
|
* Setup the toolbarSaveButton property to point to the save tool
|
|
*
|
|
* @method
|
|
* @abstract
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setupToolbarSaveButton = null;
|
|
|
|
/**
|
|
* Re-evaluate whether the article can be saved
|
|
*
|
|
* @return {boolean} The article can be saved
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.isSaveable = function () {
|
|
const surface = this.getSurface();
|
|
if ( !surface ) {
|
|
// Called before we're attached, so meaningless; abandon for now
|
|
return false;
|
|
}
|
|
|
|
this.edited =
|
|
// Document was edited before loading
|
|
this.fromEditedState ||
|
|
// Document was edited
|
|
surface.getModel().hasBeenModified() ||
|
|
// Section title (if it exists) was edited
|
|
( !!this.sectionTitle && this.sectionTitle.getValue() !== '' );
|
|
|
|
return this.edited || this.restoring;
|
|
};
|
|
|
|
/**
|
|
* Update the toolbar save button to reflect if the article can be saved
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.updateToolbarSaveButtonState = function () {
|
|
// This should really be an emit('updateState') but that would cause
|
|
// every tool to be updated on every transaction.
|
|
this.toolbarSaveButton.onUpdateState();
|
|
};
|
|
|
|
/**
|
|
* Show a save dialog
|
|
*
|
|
* @param {string} [action] Window action to trigger after opening
|
|
* @param {string} [checkboxName] Checkbox to toggle after opening
|
|
*
|
|
* @fires ve.init.mw.ArticleTarget#saveWorkflowBegin
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.showSaveDialog = function ( action, checkboxName ) {
|
|
let firstLoad = false;
|
|
|
|
if ( !this.isSaveable() || this.saveDialogIsOpening ) {
|
|
return;
|
|
}
|
|
|
|
const currentWindow = this.getSurface().getDialogs().getCurrentWindow();
|
|
if ( currentWindow && currentWindow.constructor.static.name === 'mwSave' && ( action === 'save' || action === null ) ) {
|
|
// The current window is the save dialog, and we've gotten here via
|
|
// the save action. Trigger a save. We're doing this here instead of
|
|
// relying on an accesskey on the save button, because that has some
|
|
// cross-browser issues that makes it not work in Firefox.
|
|
currentWindow.executeAction( 'save' );
|
|
return;
|
|
}
|
|
|
|
this.saveDialogIsOpening = true;
|
|
|
|
const saveProcess = new OO.ui.Process();
|
|
mw.hook( 've.preSaveProcess' ).fire( saveProcess, this );
|
|
|
|
this.emit( 'saveWorkflowBegin' );
|
|
|
|
saveProcess.execute().done( () => {
|
|
// Preload the serialization
|
|
this.prepareCacheKey( this.getDocToSave() );
|
|
|
|
// Get the save dialog
|
|
this.getSurface().getDialogs().getWindow( 'mwSave' ).done( ( win ) => {
|
|
const windowAction = ve.ui.actionFactory.create( 'window', this.getSurface() );
|
|
|
|
if ( !this.saveDialog ) {
|
|
this.saveDialog = win;
|
|
firstLoad = true;
|
|
|
|
// Connect to save dialog
|
|
this.saveDialog.connect( this, {
|
|
save: 'onSaveDialogSave',
|
|
review: 'onSaveDialogReview',
|
|
preview: 'onSaveDialogPreview',
|
|
resolve: 'onSaveDialogResolveConflict',
|
|
retry: 'onSaveDialogRetry',
|
|
// The array syntax is a way to call `this.emit( 'saveWorkflowEnd' )`.
|
|
close: [ 'emit', 'saveWorkflowEnd' ]
|
|
} );
|
|
|
|
// Attach custom overlay
|
|
this.saveDialog.$element.append( this.$saveDialogOverlay );
|
|
}
|
|
|
|
const data = this.getSaveDialogOpeningData();
|
|
|
|
if (
|
|
( action === 'review' && !data.canReview ) ||
|
|
( action === 'preview' && !data.canPreview )
|
|
) {
|
|
this.saveDialogIsOpening = false;
|
|
return;
|
|
}
|
|
|
|
if ( firstLoad ) {
|
|
for ( const name in this.checkboxesByName ) {
|
|
if ( this.initialCheckboxes[ name ] !== undefined ) {
|
|
this.checkboxesByName[ name ].setSelected( this.initialCheckboxes[ name ] );
|
|
}
|
|
}
|
|
}
|
|
|
|
let checkbox;
|
|
if ( checkboxName && ( checkbox = this.checkboxesByName[ checkboxName ] ) ) {
|
|
const isSelected = !checkbox.isSelected();
|
|
// Wait for native access key change to happen
|
|
setTimeout( () => {
|
|
checkbox.setSelected( isSelected );
|
|
} );
|
|
}
|
|
|
|
// When calling review/preview action, switch to those panels immediately
|
|
if ( action === 'review' || action === 'preview' ) {
|
|
data.initialPanel = action;
|
|
}
|
|
|
|
// Open the dialog
|
|
const openPromise = windowAction.open( 'mwSave', data, action );
|
|
if ( openPromise ) {
|
|
openPromise.always( () => {
|
|
this.saveDialogIsOpening = false;
|
|
} );
|
|
}
|
|
} );
|
|
} ).fail( () => {
|
|
this.saveDialogIsOpening = false;
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get opening data to pass to the save dialog
|
|
*
|
|
* @return {Object} Opening data
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSaveDialogOpeningData = function () {
|
|
const mode = this.getSurface().getMode();
|
|
return {
|
|
canPreview: mode === 'source',
|
|
canReview: !( mode === 'source' && this.section === 'new' ),
|
|
sectionTitle: this.sectionTitle && this.sectionTitle.getValue(),
|
|
saveButtonLabel: this.getSaveButtonLabel(),
|
|
copyrightWarning: this.copyrightWarning,
|
|
checkboxFields: this.checkboxFields,
|
|
checkboxesByName: this.checkboxesByName
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Move the cursor in the editor to section specified by this.section.
|
|
* Do nothing if this.section is undefined.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.restoreEditSection = function () {
|
|
const section = this.section !== null ? this.section : this.visibleSection;
|
|
const surface = this.getSurface();
|
|
const mode = surface.getMode();
|
|
|
|
if (
|
|
mode === 'source' ||
|
|
( this.enableVisualSectionEditing && this.section !== null )
|
|
) {
|
|
this.$scrollContainer.scrollTop( 0 );
|
|
}
|
|
|
|
if ( section === null || section === 'new' || section === '0' || section === 'T-0' ) {
|
|
return;
|
|
}
|
|
|
|
const setExactScrollOffset = this.section === null && this.visibleSection !== null && this.visibleSectionOffset !== null,
|
|
// User clicked section edit link with visual section editing not available:
|
|
// Take them to the top of the section using goToHeading
|
|
goToStartOfHeading = this.section !== null && !this.enableVisualSectionEditing,
|
|
setEditSummary = this.section !== null;
|
|
|
|
let headingText;
|
|
if ( mode === 'visual' ) {
|
|
const dmDoc = surface.getModel().getDocument();
|
|
// In mw.libs.ve.unwrapParsoidSections we copy the data-mw-section-id from the section element
|
|
// to the heading. Iterate over headings to find the one with the correct attribute
|
|
// in originalDomElements.
|
|
let headingModel;
|
|
dmDoc.getNodesByType( 'mwHeading' ).some( ( heading ) => {
|
|
const domElements = heading.getOriginalDomElements( dmDoc.getStore() );
|
|
if (
|
|
domElements && domElements[ 0 ].nodeType === Node.ELEMENT_NODE &&
|
|
domElements[ 0 ].getAttribute( 'data-mw-section-id' ) === section
|
|
) {
|
|
headingModel = heading;
|
|
return true;
|
|
}
|
|
return false;
|
|
} );
|
|
if ( headingModel ) {
|
|
const headingView = surface.getView().getDocument().getDocumentNode().getNodeFromOffset( headingModel.getRange().start );
|
|
if ( setEditSummary && !new URL( location.href ).searchParams.has( 'summary' ) ) {
|
|
headingText = headingView.$element.text();
|
|
}
|
|
if ( setExactScrollOffset ) {
|
|
this.scrollToHeading( headingView, this.visibleSectionOffset );
|
|
} else if ( goToStartOfHeading ) {
|
|
this.goToHeading( headingView );
|
|
}
|
|
}
|
|
} else if ( mode === 'source' && setEditSummary ) {
|
|
// With elements of extractSectionTitle + stripSectionName TODO:
|
|
// Arguably, we should just throw this through the API and then do
|
|
// the same extract-text pass we do in visual mode. Would save us
|
|
// having to think about wikitext here.
|
|
headingText = surface.getModel().getDocument().data.getText(
|
|
false,
|
|
surface.getModel().getDocument().getDocumentNode().children[ 0 ].getRange()
|
|
)
|
|
// Extract the title
|
|
.replace( /^\s*=+\s*(.*?)\s*=+\s*$/, '$1' )
|
|
// Remove links
|
|
.replace( /\[\[:?([^[|]+)\|([^[]+)\]\]/g, '$2' )
|
|
.replace( /\[\[:?([^[]+)\|?\]\]/g, '$1' )
|
|
.replace( new RegExp( '\\[(?:' + ve.init.platform.getUnanchoredExternalLinkUrlProtocolsRegExp().source + ')([^ ]+?) ([^\\[]+)\\]', 'ig' ), '$3' )
|
|
// Cheap HTML removal
|
|
.replace( /<[^>]+?>/g, '' );
|
|
}
|
|
if ( headingText ) {
|
|
this.initialEditSummary =
|
|
'/* ' +
|
|
ve.graphemeSafeSubstring( headingText, 0, 244 ) +
|
|
' */ ';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Move the cursor to a given heading and scroll to it.
|
|
*
|
|
* @param {ve.ce.HeadingNode} headingNode Heading node to scroll to
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.goToHeading = function ( headingNode ) {
|
|
const surface = this.getSurface(),
|
|
surfaceView = surface.getView();
|
|
|
|
let offsetNode = headingNode,
|
|
lastHeadingLevel = -1;
|
|
let nextNode;
|
|
// Find next sibling which isn't a heading
|
|
while ( offsetNode instanceof ve.ce.HeadingNode && offsetNode.getModel().getAttribute( 'level' ) > lastHeadingLevel ) {
|
|
lastHeadingLevel = offsetNode.getModel().getAttribute( 'level' );
|
|
// Next sibling
|
|
nextNode = offsetNode.parent.children[ offsetNode.parent.children.indexOf( offsetNode ) + 1 ];
|
|
if ( !nextNode ) {
|
|
break;
|
|
}
|
|
offsetNode = nextNode;
|
|
}
|
|
const startOffset = offsetNode.getModel().getOffset();
|
|
|
|
function setSelection() {
|
|
surfaceView.selectRelativeSelectableContentOffset( startOffset, 1 );
|
|
}
|
|
|
|
if ( surfaceView.isFocused() ) {
|
|
setSelection();
|
|
// Focussing the document triggers showSelection which calls scrollIntoView
|
|
// which uses a jQuery animation, so make sure this is aborted.
|
|
$( OO.ui.Element.static.getClosestScrollableContainer( surfaceView.$element[ 0 ] ) ).stop( true );
|
|
} else {
|
|
// onDocumentFocus is debounced, so wait for that to happen before setting
|
|
// the model selection, otherwise it will get reset
|
|
surfaceView.once( 'focus', setSelection );
|
|
}
|
|
this.scrollToHeading( headingNode );
|
|
};
|
|
|
|
/**
|
|
* Scroll to a given heading in the document.
|
|
*
|
|
* @param {ve.ce.HeadingNode} headingNode Heading node to scroll to
|
|
* @param {number} [headingOffset=0] Set the top offset of the heading to a specific amount, relative
|
|
* to the surface viewport.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.scrollToHeading = function ( headingNode, headingOffset ) {
|
|
this.$scrollContainer.scrollTop(
|
|
headingNode.$element.offset().top - parseInt( headingNode.$element.css( 'margin-top' ) ) -
|
|
( this.getSurface().padding.top + ( headingOffset || 0 ) ) );
|
|
};
|
|
|
|
/**
|
|
* Get the URL hash for the current section's ID using the page's HTML.
|
|
*
|
|
* TODO: Do this in a less skin-dependent way
|
|
*
|
|
* @return {string} URL hash with leading '#', or empty string if not found
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getSectionHashFromPage = function () {
|
|
// Assume there are section edit links, as the user just did a section edit. This also means
|
|
// that the section numbers line up correctly, as not every H_ tag is a numbered section.
|
|
const $sections = this.$editableContent.find( '.mw-editsection' );
|
|
|
|
let section;
|
|
if ( this.section === 'new' ) {
|
|
// A new section is appended to the end, so take the last one.
|
|
section = $sections.length;
|
|
} else {
|
|
section = this.section;
|
|
}
|
|
if ( section > 0 ) {
|
|
// Compatibility with pre-T13555 markup
|
|
const $section = $sections.eq( section - 1 )
|
|
.closest( '.mw-heading, h1, h2, h3, h4, h5, h6' )
|
|
.find( 'h1, h2, h3, h4, h5, h6, .mw-headline' );
|
|
|
|
if ( $section.length && $section.attr( 'id' ) ) {
|
|
return '#' + $section.attr( 'id' );
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
/**
|
|
* Switches to the wikitext editor, either keeping (default) or discarding changes.
|
|
*
|
|
* @param {boolean} [modified=false] Whether there were any changes at all.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToWikitextEditor = function ( modified ) {
|
|
// When switching with changes we always pass the full page as changes in visual section mode
|
|
// can still affect the whole document (e.g. removing a reference)
|
|
if ( modified ) {
|
|
this.section = null;
|
|
}
|
|
|
|
if ( this.isModeAvailable( 'source' ) ) {
|
|
if ( !modified ) {
|
|
this.reloadSurface( 'source' );
|
|
} else {
|
|
const dataPromise = this.getWikitextDataPromiseForDoc( modified );
|
|
this.reloadSurface( 'source', dataPromise );
|
|
}
|
|
} else {
|
|
this.switchToFallbackWikitextEditor( modified );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get a data promise for wikitext editing based on the current doc state
|
|
*
|
|
* @param {boolean} modified Whether there were any changes
|
|
* @return {jQuery.Promise} Data promise
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.getWikitextDataPromiseForDoc = function ( modified ) {
|
|
return this.serialize( this.getDocToSave() ).then( ( data ) => {
|
|
// HACK - add parameters the API doesn't provide for a VE->WT switch
|
|
data.etag = this.etag;
|
|
data.fromEditedState = modified;
|
|
data.notices = this.remoteNotices;
|
|
data.protectedClasses = this.protectedClasses;
|
|
data.basetimestamp = this.baseTimeStamp;
|
|
data.starttimestamp = this.startTimeStamp;
|
|
data.oldid = this.revid;
|
|
data.canEdit = this.canEdit;
|
|
data.wouldautocreate = this.wouldautocreate;
|
|
data.checkboxesDef = this.checkboxesDef;
|
|
// Wrap up like a response object as that is what dataPromise is expected to be
|
|
return { visualeditoredit: data };
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Switches to the fallback wikitext editor, either keeping (default) or discarding changes.
|
|
*
|
|
* @param {boolean} [modified=false] Whether there were any changes at all.
|
|
* @return {jQuery.Promise} Promise which rejects if the switch fails
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToFallbackWikitextEditor = function () {
|
|
return ve.createDeferred().resolve().promise();
|
|
};
|
|
|
|
/**
|
|
* Switch to the visual editor.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToVisualEditor = function () {
|
|
if ( !this.edited ) {
|
|
this.reloadSurface( 'visual' );
|
|
return;
|
|
}
|
|
|
|
const url = new URL( location.href );
|
|
const dataPromise = mw.libs.ve.targetLoader.requestParsoidData( this.getPageName(), {
|
|
oldId: this.revid,
|
|
targetName: this.constructor.static.trackingName,
|
|
modified: this.edited,
|
|
wikitext: this.getDocToSave(),
|
|
section: this.section,
|
|
editintro: url.searchParams.get( 'editintro' ),
|
|
preload: url.searchParams.get( 'preload' ),
|
|
preloadparams: mw.util.getArrayParam( 'preloadparams', url.searchParams )
|
|
} );
|
|
|
|
this.reloadSurface( 'visual', dataPromise );
|
|
};
|
|
|
|
/**
|
|
* Switch to a different wikitext section
|
|
*
|
|
* @param {string|null} section Section to switch to: a number, 'T-'-prefixed number, 'new'
|
|
* or null (whole document)
|
|
* @param {boolean} [noPrompt=false] Switch without prompting (changes will be lost either way)
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.switchToWikitextSection = function ( section, noPrompt ) {
|
|
if ( section === this.section ) {
|
|
return;
|
|
}
|
|
let promise;
|
|
if ( !noPrompt && this.edited && mw.user.options.get( 'useeditwarning' ) ) {
|
|
promise = this.getSurface().dialogs.openWindow( 'abandonedit' )
|
|
.closed.then( ( data ) => data && data.action === 'discard' );
|
|
} else {
|
|
promise = ve.createDeferred().resolve( true ).promise();
|
|
}
|
|
promise.then( ( confirmed ) => {
|
|
if ( confirmed ) {
|
|
// Section has changed and edits have been discarded, so edit summary is no longer valid
|
|
// TODO: Preserve summary if document changes can be preserved
|
|
if ( this.saveDialog ) {
|
|
this.saveDialog.reset();
|
|
}
|
|
// TODO: If switching to a non-null section, get the new section title
|
|
this.initialEditSummary = null;
|
|
this.section = section;
|
|
this.reloadSurface( 'source' );
|
|
this.updateTabs();
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Reload the target surface in the new editor mode
|
|
*
|
|
* @param {string} newMode New mode
|
|
* @param {jQuery.Promise} [dataPromise] Data promise, if any
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.reloadSurface = function ( newMode, dataPromise ) {
|
|
this.setDefaultMode( newMode );
|
|
this.clearDiff();
|
|
const promise = this.load( dataPromise );
|
|
this.getSurface().createProgress(
|
|
promise,
|
|
ve.msg( newMode === 'source' ? 'visualeditor-mweditmodesource-progress' : 'visualeditor-mweditmodeve-progress' ),
|
|
true /* non-cancellable */
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Display the given redirect subtitle and redirect page content header on the page.
|
|
*
|
|
* @param {jQuery} $sub Redirect subtitle, see #buildRedirectSub
|
|
* @param {jQuery} $msg Redirect page content header, see #buildRedirectMsg
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.updateRedirectInterface = function ( $sub, $msg ) {
|
|
// For the subtitle, replace the real one with ours.
|
|
// This is more complicated than it should be because we have to fiddle with the <br>.
|
|
const $currentSub = $( '#redirectsub' );
|
|
if ( $currentSub.length ) {
|
|
if ( $sub.length ) {
|
|
$currentSub.replaceWith( $sub );
|
|
} else {
|
|
$currentSub.prev().filter( 'br' ).remove();
|
|
$currentSub.remove();
|
|
}
|
|
} else {
|
|
const $subtitle = $( '#contentSub' );
|
|
if ( $sub.length ) {
|
|
if ( $subtitle.children().length ) {
|
|
$subtitle.append( $( '<br>' ) );
|
|
}
|
|
$subtitle.append( $sub );
|
|
}
|
|
}
|
|
|
|
if ( $msg.length ) {
|
|
$msg
|
|
// We need to be able to tell apart the real one and our fake one
|
|
.addClass( 've-redirect-header' )
|
|
.on( 'click', ( e ) => {
|
|
const windowAction = ve.ui.actionFactory.create( 'window', this.getSurface() );
|
|
windowAction.open( 'meta', { page: 'settings' } );
|
|
e.preventDefault();
|
|
} );
|
|
}
|
|
// For the content header, the real one is hidden, insert ours before it.
|
|
const $currentMsg = $( '.ve-redirect-header' );
|
|
if ( $currentMsg.length ) {
|
|
$currentMsg.replaceWith( $msg );
|
|
} else {
|
|
// Hack: This is normally inside #mw-content-text, but that's hidden while editing.
|
|
$( '#mw-content-text' ).before( $msg );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set temporary redirect interface to match the current state of redirection in the editor.
|
|
*
|
|
* @param {string|null} title Current redirect target, or null if none
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setFakeRedirectInterface = function ( title ) {
|
|
this.isRedirect = !!title;
|
|
this.updateRedirectInterface(
|
|
title ? this.constructor.static.buildRedirectSub() : $(),
|
|
title ? this.constructor.static.buildRedirectMsg( title ) : $()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Set the redirect interface to match the page's redirect state.
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.setRealRedirectInterface = function () {
|
|
this.updateRedirectInterface(
|
|
mw.config.get( 'wgIsRedirect' ) ? this.constructor.static.buildRedirectSub() : $(),
|
|
// Remove our custom content header - the original one in #mw-content-text will be shown
|
|
$()
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Render a list of categories
|
|
*
|
|
* Duplicate items are not shown.
|
|
*
|
|
* @param {ve.dm.MetaItem[]} categoryItems Array of category metaitems to display
|
|
* @return {jQuery.Promise} A promise which will be resolved with the rendered categories
|
|
*/
|
|
ve.init.mw.ArticleTarget.prototype.renderCategories = function ( categoryItems ) {
|
|
const promises = [],
|
|
categories = { hidden: {}, normal: {} };
|
|
categoryItems.forEach( ( categoryItem, index ) => {
|
|
const attributes = ve.copy( ve.getProp( categoryItem, 'element', 'attributes' ) );
|
|
attributes.index = index;
|
|
promises.push( ve.init.platform.linkCache.get( attributes.category ).done( ( result ) => {
|
|
const group = result.hidden ? categories.hidden : categories.normal;
|
|
// In case of duplicates, first entry wins (like in MediaWiki)
|
|
if ( !group[ attributes.category ] || group[ attributes.category ].index > attributes.index ) {
|
|
group[ attributes.category ] = attributes;
|
|
}
|
|
} ) );
|
|
} );
|
|
return ve.promiseAll( promises ).then( () => {
|
|
const $output = $( '<div>' ).addClass( 'catlinks' );
|
|
function renderPageLink( page ) {
|
|
const title = mw.Title.newFromText( page ),
|
|
$link = $( '<a>' ).attr( 'rel', 'mw:WikiLink' ).attr( 'href', title.getUrl() ).text( title.getMainText() );
|
|
// Style missing links. The data should already have been fetched
|
|
// as part of the earlier processing of categoryItems.
|
|
ve.init.platform.linkCache.styleElement( title.getPrefixedText(), $link, false );
|
|
return $link;
|
|
}
|
|
function renderPageLinks( pages ) {
|
|
const $list = $( '<ul>' );
|
|
for ( let i = 0; i < pages.length; i++ ) {
|
|
const $link = renderPageLink( pages[ i ] );
|
|
$list.append( $( '<li>' ).append( $link ) );
|
|
}
|
|
return $list;
|
|
}
|
|
function categorySort( group, a, b ) {
|
|
return group[ a ].index - group[ b ].index;
|
|
}
|
|
const categoriesNormal = Object.keys( categories.normal );
|
|
if ( categoriesNormal.length ) {
|
|
categoriesNormal.sort( categorySort.bind( null, categories.normal ) );
|
|
const $normal = $( '<div>' ).addClass( 'mw-normal-catlinks' );
|
|
const $pageLink = renderPageLink( ve.msg( 'pagecategorieslink' ) ).text( ve.msg( 'pagecategories', categoriesNormal.length ) );
|
|
const $pageLinks = renderPageLinks( categoriesNormal );
|
|
$normal.append(
|
|
$pageLink,
|
|
$( document.createTextNode( ve.msg( 'colon-separator' ) ) ),
|
|
$pageLinks
|
|
);
|
|
$output.append( $normal );
|
|
}
|
|
const categoriesHidden = Object.keys( categories.hidden );
|
|
if ( categoriesHidden.length ) {
|
|
categoriesHidden.sort( categorySort.bind( null, categories.hidden ) );
|
|
const $hidden = $( '<div>' ).addClass( 'mw-hidden-catlinks' );
|
|
if ( mw.user.options.get( 'showhiddencats' ) ) {
|
|
$hidden.addClass( 'mw-hidden-cats-user-shown' );
|
|
} else if ( mw.config.get( 'wgNamespaceIds' ).category === mw.config.get( 'wgNamespaceNumber' ) ) {
|
|
$hidden.addClass( 'mw-hidden-cats-ns-shown' );
|
|
} else {
|
|
$hidden.addClass( 'mw-hidden-cats-hidden' );
|
|
}
|
|
const $hiddenPageLinks = renderPageLinks( categoriesHidden );
|
|
$hidden.append(
|
|
$( document.createTextNode( ve.msg( 'hidden-categories', categoriesHidden.length ) ) ),
|
|
$( document.createTextNode( ve.msg( 'colon-separator' ) ) ),
|
|
$hiddenPageLinks
|
|
);
|
|
$output.append( $hidden );
|
|
}
|
|
return $output;
|
|
} );
|
|
};
|
|
|
|
// Used in tryTeardown
|
|
ve.ui.windowFactory.register( mw.widgets.AbandonEditDialog );
|