mediawiki-extensions-Visual.../modules/ve-mw/ui/ve.ui.MWExtensionWindow.js
Zoë 3c6f0a918c Change confirmation behaviour when abandoning template edits
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
2024-05-28 15:49:19 +01:00

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 '&lt;'. (T59429)
value = value.replace( new RegExp( '<(/' + tagName + '\\s*>)', 'gi' ), '&lt;$1' );
if ( value.trim() === '' && this.constructor.static.selfCloseEmptyBody ) {
delete mwData.body;
} else {
mwData.body = mwData.body || {};
mwData.body.extsrc = value;
}
};