mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-12-03 18:36:20 +00:00
3c6f0a918c
Previously, it was possible to close a dialog with active edits by pressing the "<" button or pressing escape. A change was made to confirm the user's intent before abandoning their changes, see Ia8935b5b1acb This patch fixes a bug where the user's intent is always confirmed while editing a template, even if the user has made no changes. This was because for technical reasons we trimmed whitespace before making a comparison with the new template case, but that caused the comparison with the edit case to always fail because existing templates are padded with whitespace. This could have been solved by moving the trim operation into the new template flow. This patch would still have been necessary to prevent a bug if the default value had trimmable whitespace. I have opted to keep the whitespace behaviour for edits for consistency. Bug: T334513 Change-Id: I7b3370c86df67c36fc63a1f1d0e7004a098a1950
310 lines
8.7 KiB
JavaScript
310 lines
8.7 KiB
JavaScript
/*!
|
|
* VisualEditor UserInterface MWExtensionWindow class.
|
|
*
|
|
* @copyright See AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
|
|
/**
|
|
* Mixin for windows for editing generic MediaWiki extensions.
|
|
*
|
|
* @class
|
|
* @abstract
|
|
*
|
|
* @constructor
|
|
* @param {Object} [config] Configuration options
|
|
*/
|
|
ve.ui.MWExtensionWindow = function VeUiMWExtensionWindow() {
|
|
this.whitespace = null;
|
|
this.input = null;
|
|
this.originalMwData = null;
|
|
|
|
this.onChangeHandler = ve.debounce( this.onChange.bind( this ) );
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
OO.initClass( ve.ui.MWExtensionWindow );
|
|
|
|
/* Static properties */
|
|
|
|
/**
|
|
* Extension is allowed to have empty contents
|
|
*
|
|
* @static
|
|
* @property {boolean}
|
|
* @inheritable
|
|
*/
|
|
ve.ui.MWExtensionWindow.static.allowedEmpty = false;
|
|
|
|
/**
|
|
* Tell Parsoid to self-close tags when the body is empty
|
|
*
|
|
* i.e. `<foo></foo>` -> `<foo/>`
|
|
*
|
|
* @static
|
|
* @property {boolean}
|
|
* @inheritable
|
|
*/
|
|
ve.ui.MWExtensionWindow.static.selfCloseEmptyBody = false;
|
|
|
|
/**
|
|
* Inspector's directionality, 'ltr' or 'rtl'
|
|
*
|
|
* Leave as null to use the directionality of the current fragment.
|
|
*
|
|
* @static
|
|
* @property {string|null}
|
|
* @inheritable
|
|
*/
|
|
ve.ui.MWExtensionWindow.static.dir = null;
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* @inheritdoc OO.ui.Window
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.initialize = function () {
|
|
this.input = new ve.ui.WhitespacePreservingTextInputWidget( {
|
|
limit: 1,
|
|
classes: [ 've-ui-mwExtensionWindow-input' ]
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Get the placeholder text for the content input area.
|
|
*
|
|
* @return {string} Placeholder text
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getInputPlaceholder = function () {
|
|
return '';
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc OO.ui.Window
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getSetupProcess = function ( data, process ) {
|
|
data = data || {};
|
|
return process.next( () => {
|
|
// Initialization
|
|
this.whitespace = [ '', '' ];
|
|
|
|
if ( this.selectedNode ) {
|
|
var mwData = this.selectedNode.getAttribute( 'mw' );
|
|
// mwData.body can be null in <selfclosing/> extensions
|
|
this.input.setValueAndWhitespace( ( mwData.body && mwData.body.extsrc ) || '' );
|
|
this.originalMwData = mwData;
|
|
} else {
|
|
if ( !this.constructor.static.modelClasses[ 0 ].static.isContent ) {
|
|
// New nodes should use linebreaks for blocks
|
|
this.input.setWhitespace( [ '\n', '\n' ] );
|
|
}
|
|
this.input.setValue( '' );
|
|
}
|
|
|
|
this.input.$input.attr( 'placeholder', this.getInputPlaceholder() );
|
|
|
|
var dir = this.constructor.static.dir || data.dir;
|
|
this.input.setDir( dir );
|
|
this.input.setReadOnly( this.isReadOnly() );
|
|
|
|
this.actions.setAbilities( { done: false } );
|
|
this.input.connect( this, { change: 'onChangeHandler' } );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc OO.ui.Window
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getReadyProcess = function ( data, process ) {
|
|
return process;
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc OO.ui.Window
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getTeardownProcess = function ( data, process ) {
|
|
return process.next( () => {
|
|
// Don't hold on to the original data, it's only refreshed on setup for existing nodes
|
|
this.originalMwData = null;
|
|
this.input.disconnect( this, { change: 'onChangeHandler' } );
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* @inheritdoc OO.ui.Dialog
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getActionProcess = function ( action, process ) {
|
|
return process.first( () => {
|
|
if ( action === 'done' ) {
|
|
if ( this.constructor.static.allowedEmpty || this.input.getValue() !== '' ) {
|
|
this.insertOrUpdateNode();
|
|
} else if ( this.selectedNode && !this.constructor.static.allowedEmpty ) {
|
|
// Content has been emptied on a node which isn't allowed to
|
|
// be empty, so delete it.
|
|
this.removeNode();
|
|
}
|
|
}
|
|
} );
|
|
};
|
|
|
|
/**
|
|
* Handle change event.
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.onChange = function () {
|
|
this.updateActions();
|
|
};
|
|
|
|
/**
|
|
* Update the 'done' action according to whether there are changes
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.updateActions = function () {
|
|
this.actions.setAbilities( { done: this.isSaveable() } );
|
|
};
|
|
|
|
/**
|
|
* Check if mwData would be modified if window contents were applied.
|
|
* This is used to determine if it's meaningful for the user to save the
|
|
* contents into the document; this is likely true of newly-created elements.
|
|
*
|
|
* @return {boolean} mwData would be modified
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.isSaveable = function () {
|
|
var modified;
|
|
if ( this.originalMwData ) {
|
|
var mwDataCopy = ve.copy( this.originalMwData );
|
|
this.updateMwData( mwDataCopy );
|
|
modified = !ve.compare( this.originalMwData, mwDataCopy );
|
|
} else {
|
|
modified = true;
|
|
}
|
|
return modified;
|
|
};
|
|
|
|
/**
|
|
* @deprecated Moved to ve.ui.MWExtensionWindow.prototype.isSaveable
|
|
* @return {boolean} mwData would be modified
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.isModified = ve.ui.MWExtensionWindow.prototype.isSaveable;
|
|
|
|
/**
|
|
* Check if mwData has meaningful edits. This is used to determine if it's
|
|
* meaningful to warn the user before closing the dialog without saving. Unlike
|
|
* `isModified()` above, we consider a newly-created but unmodified element to
|
|
* be non-meaningful because the user can simply re-open the dialog to restore
|
|
* their state.
|
|
*
|
|
* @return {boolean} mwData would contain new user input
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.hasMeaningfulEdits = function () {
|
|
let mwDataBaseline;
|
|
if ( this.originalMwData ) {
|
|
mwDataBaseline = this.originalMwData;
|
|
} else {
|
|
mwDataBaseline = this.getNewElement().attributes.mw;
|
|
}
|
|
const mwDataCopy = ve.copy( mwDataBaseline );
|
|
this.updateMwData( mwDataCopy );
|
|
|
|
// We have some difficulty here. `updateMwData()` in this class calls on
|
|
// `this.input.getValueAndWhitespace()`. The 'and whitespace' means that
|
|
// we cannot directly compare a new element's mwData with a newly-opened
|
|
// dialog's mwData because it may have additional newlines.
|
|
// We don't want to touch `this.input` or `prototype.updateMwData` because
|
|
// they're overridden in subclasses. Therefore, we consider whitespace-only
|
|
// changes to a new element to be non-meaningful too.
|
|
const changed = OO.getProp( mwDataCopy, 'body', 'extsrc' );
|
|
if ( changed !== undefined ) {
|
|
OO.setProp( mwDataCopy, 'body', 'extsrc', changed.trim() );
|
|
}
|
|
|
|
// Also trim the baseline. In "edit" mode we likely have added whitespace,
|
|
// and in "insert" mode we don't want to break if the default value starts
|
|
// or ends with whitespace.
|
|
const baselineChanged = OO.getProp( mwDataBaseline, 'body', 'extsrc' );
|
|
if ( baselineChanged !== undefined ) {
|
|
OO.setProp( mwDataBaseline, 'body', 'extsrc', baselineChanged.trim() );
|
|
}
|
|
|
|
return !ve.compare( mwDataBaseline, mwDataCopy );
|
|
};
|
|
|
|
/**
|
|
* Create an new data element for the model class associated with this inspector
|
|
*
|
|
* @return {Object} Element data
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.getNewElement = function () {
|
|
// Extension inspectors which create elements should either match
|
|
// a single modelClass or override this method.
|
|
var modelClass = this.constructor.static.modelClasses[ 0 ];
|
|
return {
|
|
type: modelClass.static.name,
|
|
attributes: {
|
|
mw: {
|
|
name: modelClass.static.extensionName,
|
|
attrs: {},
|
|
body: {
|
|
extsrc: ''
|
|
}
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Insert or update the node in the document model from the new values
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.insertOrUpdateNode = function () {
|
|
var surfaceModel = this.getFragment().getSurface();
|
|
if ( this.selectedNode ) {
|
|
var mwData = ve.copy( this.selectedNode.getAttribute( 'mw' ) );
|
|
this.updateMwData( mwData );
|
|
surfaceModel.change(
|
|
ve.dm.TransactionBuilder.static.newFromAttributeChanges(
|
|
surfaceModel.getDocument(),
|
|
this.selectedNode.getOuterRange().start,
|
|
{ mw: mwData }
|
|
)
|
|
);
|
|
} else {
|
|
var element = this.getNewElement();
|
|
this.updateMwData( element.attributes.mw );
|
|
// Collapse returns a new fragment, so update this.fragment
|
|
this.fragment = this.getFragment().collapseToEnd();
|
|
this.getFragment().insertContent( [
|
|
element,
|
|
{ type: '/' + element.type }
|
|
] );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Remove the node form the document model
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.removeNode = function () {
|
|
this.getFragment().removeContent();
|
|
};
|
|
|
|
/**
|
|
* Update mwData object with the new values from the inspector or dialog
|
|
*
|
|
* @param {Object} mwData MediaWiki data object
|
|
*/
|
|
ve.ui.MWExtensionWindow.prototype.updateMwData = function ( mwData ) {
|
|
var tagName = mwData.name,
|
|
value = this.input.getValueAndWhitespace();
|
|
|
|
// XML-like tags in wikitext are not actually XML and don't expect their contents to be escaped.
|
|
// This means that it is not possible for a tag '<foo>…</foo>' to contain the string '</foo>'.
|
|
// Prevent that by escaping the first angle bracket '<' to '<'. (T59429)
|
|
value = value.replace( new RegExp( '<(/' + tagName + '\\s*>)', 'gi' ), '<$1' );
|
|
|
|
if ( value.trim() === '' && this.constructor.static.selfCloseEmptyBody ) {
|
|
delete mwData.body;
|
|
} else {
|
|
mwData.body = mwData.body || {};
|
|
mwData.body.extsrc = value;
|
|
}
|
|
};
|