mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/VisualEditor
synced 2024-11-15 18:39:52 +00:00
d6ca977397
Becuase big/small stack doesn't really mean anything. Bonus: rename the transactions property of the stack item from 'stack' to 'transactions'. Bug: 49754 Change-Id: I361dda49f4c1e58a541df5b9e478cf20957939a1
603 lines
16 KiB
JavaScript
603 lines
16 KiB
JavaScript
/*!
|
|
* VisualEditor DataModel Surface class.
|
|
*
|
|
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
|
|
* @license The MIT License (MIT); see LICENSE.txt
|
|
*/
|
|
|
|
/**
|
|
* DataModel surface.
|
|
*
|
|
* @class
|
|
* @mixins OO.EventEmitter
|
|
*
|
|
* @constructor
|
|
* @param {ve.dm.Document} doc Document model to create surface for
|
|
*/
|
|
ve.dm.Surface = function VeDmSurface( doc ) {
|
|
// Mixin constructors
|
|
OO.EventEmitter.call( this );
|
|
|
|
// Properties
|
|
this.documentModel = doc;
|
|
this.metaList = new ve.dm.MetaList( this );
|
|
this.selection = new ve.Range( 1, 1 );
|
|
this.selectedNodes = {};
|
|
this.newTransactions = [];
|
|
this.undoStack = [];
|
|
this.undoIndex = 0;
|
|
this.historyTrackingInterval = null;
|
|
this.insertionAnnotations = new ve.dm.AnnotationSet( this.documentModel.getStore() );
|
|
this.enabled = true;
|
|
this.transacting = false;
|
|
this.queueingContextChanges = false;
|
|
this.contextChangeQueued = false;
|
|
|
|
// Events
|
|
this.documentModel.connect( this, { 'transact': 'onDocumentTransact' } );
|
|
};
|
|
|
|
/* Inheritance */
|
|
|
|
OO.mixinClass( ve.dm.Surface, OO.EventEmitter );
|
|
|
|
/* Events */
|
|
|
|
/**
|
|
* @event select
|
|
* @param {ve.Range} selection
|
|
*/
|
|
|
|
/**
|
|
* @event transact
|
|
* @param {ve.dm.Transaction[]} transactions Transactions that have just been processed
|
|
*/
|
|
|
|
/**
|
|
* @event documentUpdate
|
|
*
|
|
* Emitted when a transaction has been processed on the document and the selection has been
|
|
* translated to account for that transaction. You should only use this event if you need
|
|
* to access the selection; in most cases, you should use {ve.dm.Document#event-transact}.
|
|
*
|
|
* @param {ve.dm.Transaction} tx Transaction that was processed on the document
|
|
*/
|
|
|
|
/**
|
|
* @event contextChange
|
|
*/
|
|
|
|
/**
|
|
* @event history
|
|
*/
|
|
|
|
/* Methods */
|
|
|
|
/**
|
|
* Disable changes.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.dm.Surface.prototype.disable = function () {
|
|
this.stopHistoryTracking();
|
|
this.enabled = false;
|
|
};
|
|
|
|
/**
|
|
* Enable changes.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.dm.Surface.prototype.enable = function () {
|
|
this.enabled = true;
|
|
this.startHistoryTracking();
|
|
};
|
|
|
|
/**
|
|
* Start tracking state changes in history.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.dm.Surface.prototype.startHistoryTracking = function () {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
if ( this.historyTrackingInterval === null ) {
|
|
this.historyTrackingInterval = setInterval( ve.bind( this.breakpoint, this ), 750 );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Stop tracking state changes in history.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.dm.Surface.prototype.stopHistoryTracking = function () {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
if ( this.historyTrackingInterval !== null ) {
|
|
clearInterval( this.historyTrackingInterval );
|
|
this.historyTrackingInterval = null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Remove all states from history.
|
|
*
|
|
* @method
|
|
*/
|
|
ve.dm.Surface.prototype.purgeHistory = function () {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
this.selection = new ve.Range( 0, 0 );
|
|
this.newTransactions = [];
|
|
this.undoStack = [];
|
|
this.undoIndex = 0;
|
|
};
|
|
|
|
/**
|
|
* Get a list of all history states.
|
|
*
|
|
* @method
|
|
* @returns {Object[]} List of transaction stacks
|
|
*/
|
|
ve.dm.Surface.prototype.getHistory = function () {
|
|
if ( this.newTransactions.length > 0 ) {
|
|
return this.undoStack.slice( 0 ).concat( [{ 'transactions': this.newTransactions.slice( 0 ) }] );
|
|
} else {
|
|
return this.undoStack.slice( 0 );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get annotations that will be used upon insertion.
|
|
*
|
|
* @method
|
|
* @returns {ve.dm.AnnotationSet} Insertion anotations
|
|
*/
|
|
ve.dm.Surface.prototype.getInsertionAnnotations = function () {
|
|
return this.insertionAnnotations.clone();
|
|
};
|
|
|
|
/**
|
|
* Set annotations that will be used upon insertion.
|
|
*
|
|
* @method
|
|
* @param {ve.dm.AnnotationSet|null} Insertion anotations to use or null to disable them
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.setInsertionAnnotations = function ( annotations ) {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
this.insertionAnnotations = annotations !== null ?
|
|
annotations.clone() :
|
|
new ve.dm.AnnotationSet( this.documentModel.getStore() );
|
|
|
|
this.emit( 'contextChange' );
|
|
};
|
|
|
|
/**
|
|
* Add an annotation to be used upon insertion.
|
|
*
|
|
* @method
|
|
* @param {ve.dm.Annotation|ve.dm.AnnotationSet} annotations Insertion annotation to add
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.addInsertionAnnotations = function ( annotations ) {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
if ( annotations instanceof ve.dm.Annotation ) {
|
|
this.insertionAnnotations.push( annotations );
|
|
} else if ( annotations instanceof ve.dm.AnnotationSet ) {
|
|
this.insertionAnnotations.addSet( annotations );
|
|
} else {
|
|
throw new Error( 'Invalid annotations' );
|
|
}
|
|
this.emit( 'contextChange' );
|
|
};
|
|
|
|
/**
|
|
* Remove an annotation from those that will be used upon insertion.
|
|
*
|
|
* @method
|
|
* @param {ve.dm.Annotation|ve.dm.AnnotationSet} annotations Insertion annotation to remove
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.removeInsertionAnnotations = function ( annotations ) {
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
if ( annotations instanceof ve.dm.Annotation ) {
|
|
this.insertionAnnotations.remove( annotations );
|
|
} else if ( annotations instanceof ve.dm.AnnotationSet ) {
|
|
this.insertionAnnotations.removeSet( annotations );
|
|
} else {
|
|
throw new Error( 'Invalid annotations' );
|
|
}
|
|
this.emit( 'contextChange' );
|
|
};
|
|
|
|
/**
|
|
* Check if there is a state to redo.
|
|
*
|
|
* @method
|
|
* @returns {boolean} Has a future state
|
|
*/
|
|
ve.dm.Surface.prototype.hasFutureState = function () {
|
|
return this.undoIndex > 0;
|
|
};
|
|
|
|
/**
|
|
* Check if there is a state to undo.
|
|
*
|
|
* @method
|
|
* @returns {boolean} Has a past state
|
|
*/
|
|
ve.dm.Surface.prototype.hasPastState = function () {
|
|
return this.undoStack.length - this.undoIndex > 0 || !!this.newTransactions.length;
|
|
};
|
|
|
|
/**
|
|
* Get the document model.
|
|
*
|
|
* @method
|
|
* @returns {ve.dm.Document} Document model of the surface
|
|
*/
|
|
ve.dm.Surface.prototype.getDocument = function () {
|
|
return this.documentModel;
|
|
};
|
|
|
|
/**
|
|
* Get the meta list.
|
|
*
|
|
* @method
|
|
* @returns {ve.dm.MetaList} Meta list of the surface
|
|
*/
|
|
ve.dm.Surface.prototype.getMetaList = function () {
|
|
return this.metaList;
|
|
};
|
|
|
|
/**
|
|
* Get the selection.
|
|
*
|
|
* @method
|
|
* @returns {ve.Range} Current selection
|
|
*/
|
|
ve.dm.Surface.prototype.getSelection = function () {
|
|
return this.selection;
|
|
};
|
|
|
|
/**
|
|
* Get a fragment for a range.
|
|
*
|
|
* @method
|
|
* @param {ve.Range} [range] Range within target document, current selection used by default
|
|
* @param {boolean} [noAutoSelect] Don't update the surface's selection when making changes
|
|
*/
|
|
ve.dm.Surface.prototype.getFragment = function ( range, noAutoSelect ) {
|
|
return new ve.dm.SurfaceFragment( this, range || this.selection, noAutoSelect );
|
|
};
|
|
|
|
/**
|
|
* Prevent future states from being redone.
|
|
*
|
|
* @method
|
|
* @fires history
|
|
*/
|
|
ve.dm.Surface.prototype.truncateUndoStack = function () {
|
|
if ( this.undoIndex ) {
|
|
this.undoStack = this.undoStack.slice( 0, this.undoStack.length - this.undoIndex );
|
|
this.undoIndex = 0;
|
|
this.emit( 'history' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Start queueing up calls to {#emitContextChange} until {#stopQueueingContextChanges} is called.
|
|
* While queueing is active, contextChanges are also collapsed, so if {#emitContextChange} is called
|
|
* multiple times, only one contextChange event will be emitted by {#stopQueueingContextChanges}.
|
|
*
|
|
* @example
|
|
* this.emitContextChange(); // emits immediately
|
|
* this.startQueueingContextChanges();
|
|
* this.emitContextChange(); // doesn't emit
|
|
* this.emitContextChange(); // doesn't emit
|
|
* this.stopQueueingContextChanges(); // emits one contextChange event
|
|
*
|
|
* @method
|
|
* @private
|
|
*/
|
|
ve.dm.Surface.prototype.startQueueingContextChanges = function () {
|
|
if ( !this.queueingContextChanges ) {
|
|
this.queueingContextChanges = true;
|
|
this.contextChangeQueued = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Emit a contextChange event. If {#startQueueingContextChanges} has been called, then the event
|
|
* is deferred until {#stopQueueingContextChanges} is called.
|
|
*
|
|
* @method
|
|
* @private
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.emitContextChange = function () {
|
|
if ( this.queueingContextChanges ) {
|
|
this.contextChangeQueued = true;
|
|
} else {
|
|
this.emit( 'contextChange' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Stop queueing contextChange events. If {#emitContextChange} was called previously, a contextChange
|
|
* event will now be emitted. Any future calls to {#emitContextChange} will once again emit the
|
|
* event immediately.
|
|
*
|
|
* @method
|
|
* @private
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.stopQueueingContextChanges = function () {
|
|
if ( this.queueingContextChanges ) {
|
|
this.queueingContextChanges = false;
|
|
if ( this.contextChangeQueued ) {
|
|
this.contextChangeQueued = false;
|
|
this.emit( 'contextChange' );
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Change the selection
|
|
*
|
|
* @param {ve.Range} selection New selection
|
|
*
|
|
* @fires select
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.setSelection = function ( selection ) {
|
|
var left, right, leftAnnotations, rightAnnotations, insertionAnnotations,
|
|
selectedNodes = {},
|
|
oldSelection = this.selection,
|
|
contextChange = false,
|
|
dataModelData = this.documentModel.data;
|
|
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
|
|
if ( this.transacting ) {
|
|
// Update the selection but don't do any processing
|
|
this.selection = selection;
|
|
return;
|
|
}
|
|
|
|
// Detect if selected nodes changed
|
|
selectedNodes.start = this.documentModel.getNodeFromOffset( selection.start );
|
|
if ( selection.getLength() ) {
|
|
selectedNodes.end = this.documentModel.getNodeFromOffset( selection.end );
|
|
}
|
|
if (
|
|
selectedNodes.start !== this.selectedNodes.start ||
|
|
selectedNodes.end !== this.selectedNodes.end
|
|
) {
|
|
contextChange = true;
|
|
}
|
|
|
|
// Update state
|
|
this.selectedNodes = selectedNodes;
|
|
this.selection = selection;
|
|
|
|
// Figure out which annotations to use for insertions
|
|
if ( this.selection.isCollapsed() ) {
|
|
// Get annotations from the left of the cursor
|
|
left = dataModelData.getNearestContentOffset( Math.max( 0, this.selection.start - 1 ), -1 );
|
|
right = dataModelData.getNearestContentOffset( Math.max( 0, this.selection.start ) );
|
|
} else {
|
|
// Get annotations from the first character of the selection
|
|
left = dataModelData.getNearestContentOffset( this.selection.start );
|
|
right = dataModelData.getNearestContentOffset( this.selection.end );
|
|
}
|
|
if ( left === -1 ) {
|
|
// Document is empty, use empty set
|
|
insertionAnnotations = new ve.dm.AnnotationSet( this.documentModel.getStore() );
|
|
} else {
|
|
// Include annotations on the left that should be added to appended content, or ones that
|
|
// are on both the left and the right that should not
|
|
leftAnnotations = dataModelData.getAnnotationsFromOffset( left );
|
|
rightAnnotations = dataModelData.getAnnotationsFromOffset( right );
|
|
insertionAnnotations = leftAnnotations.filter( function ( annotation ) {
|
|
return annotation.constructor.static.applyToAppendedContent ||
|
|
rightAnnotations.containsComparable( annotation );
|
|
} );
|
|
}
|
|
|
|
// Only emit an annotations change event if there's a meaningful difference
|
|
if (
|
|
!insertionAnnotations.containsAllOf( this.insertionAnnotations ) ||
|
|
!this.insertionAnnotations.containsAllOf( insertionAnnotations )
|
|
) {
|
|
this.setInsertionAnnotations( insertionAnnotations );
|
|
contextChange = true;
|
|
}
|
|
|
|
// Emit events
|
|
if ( !oldSelection || !oldSelection.equals( this.selection ) ) {
|
|
this.emit( 'select', this.selection.clone() );
|
|
}
|
|
if ( contextChange ) {
|
|
this.emitContextChange();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Apply a transactions and selection changes to the document.
|
|
*
|
|
* @method
|
|
* @param {ve.dm.Transaction|ve.dm.Transaction[]|null} transactions One or more transactions to
|
|
* process, or null to process none
|
|
* @param {ve.Range} [selection] Selection to apply
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.change = function ( transactions, selection ) {
|
|
this.changeInternal( transactions, selection, false );
|
|
};
|
|
|
|
/**
|
|
* Internal implementation of change(). Do not use this, use change() instead.
|
|
*
|
|
* @private
|
|
* @method
|
|
* @param {ve.dm.Transaction|ve.dm.Transaction[]|null} transactions
|
|
* @param {ve.Range} [selection] [selection]
|
|
* @param {boolean} [skipUndoStack=false] If true, do not modify the undo stack. Used by undo/redo
|
|
* @fires contextChange
|
|
*/
|
|
ve.dm.Surface.prototype.changeInternal = function ( transactions, selection, skipUndoStack ) {
|
|
var i, len, selectionAfter, selectionBefore = this.selection, contextChange = false;
|
|
|
|
if ( !this.enabled ) {
|
|
return;
|
|
}
|
|
|
|
this.startQueueingContextChanges();
|
|
|
|
// Process transactions
|
|
if ( transactions ) {
|
|
if ( transactions instanceof ve.dm.Transaction ) {
|
|
transactions = [transactions];
|
|
}
|
|
this.transacting = true;
|
|
for ( i = 0, len = transactions.length; i < len; i++ ) {
|
|
if ( !transactions[i].isNoOp() ) {
|
|
if ( !skipUndoStack ) {
|
|
this.truncateUndoStack();
|
|
this.newTransactions.push( transactions[i] );
|
|
}
|
|
// The .commit() call below indirectly invokes setSelection()
|
|
this.documentModel.commit( transactions[i] );
|
|
if ( transactions[i].hasElementAttributeOperations() ) {
|
|
contextChange = true;
|
|
}
|
|
}
|
|
}
|
|
this.transacting = false;
|
|
}
|
|
selectionAfter = this.selection;
|
|
|
|
// Apply selection change
|
|
if ( selection ) {
|
|
this.setSelection( selection );
|
|
} else if ( transactions ) {
|
|
// Call setSelection() to trigger selection processing that was bypassed earlier
|
|
this.setSelection( this.selection );
|
|
}
|
|
|
|
// If the selection changed while applying the transactions but not while applying the
|
|
// selection change, setSelection() won't have emitted a 'select' event. We don't want that
|
|
// to happen, so emit one anyway.
|
|
if ( !selectionBefore.equals( selectionAfter ) && selectionAfter.equals( this.selection ) ) {
|
|
this.emit( 'select', this.selection.clone() );
|
|
}
|
|
|
|
if ( contextChange ) {
|
|
this.emitContextChange();
|
|
}
|
|
|
|
this.stopQueueingContextChanges();
|
|
};
|
|
|
|
/**
|
|
* Set a history state breakpoint.
|
|
*
|
|
* @method
|
|
* @param {ve.Range} selection New selection range
|
|
* @fires history
|
|
* @returns {boolean} A breakpoint was added
|
|
*/
|
|
ve.dm.Surface.prototype.breakpoint = function ( selection ) {
|
|
if ( !this.enabled ) {
|
|
return false;
|
|
}
|
|
if ( this.newTransactions.length > 0 ) {
|
|
this.undoStack.push( {
|
|
'transactions': this.newTransactions,
|
|
'selection': selection || this.selection.clone()
|
|
} );
|
|
this.newTransactions = [];
|
|
this.emit( 'history' );
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Step backwards in history.
|
|
*
|
|
* @method
|
|
* @fires history
|
|
*/
|
|
ve.dm.Surface.prototype.undo = function () {
|
|
var i, item, selection, transaction, transactions = [];
|
|
if ( !this.enabled || !this.hasPastState() ) {
|
|
return;
|
|
}
|
|
|
|
this.breakpoint();
|
|
this.undoIndex++;
|
|
|
|
item = this.undoStack[this.undoStack.length - this.undoIndex];
|
|
if ( item ) {
|
|
// Apply reversed transactions in reversed order, and translate the selection accordingly
|
|
selection = item.selection;
|
|
for ( i = item.transactions.length - 1; i >= 0; i-- ) {
|
|
transaction = item.transactions[i].reversed();
|
|
selection = transaction.translateRange( selection );
|
|
transactions.push( transaction );
|
|
}
|
|
this.changeInternal( transactions, selection, true );
|
|
this.emit( 'history' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Step forwards in history.
|
|
*
|
|
* @method
|
|
* @fires history
|
|
*/
|
|
ve.dm.Surface.prototype.redo = function () {
|
|
var item;
|
|
if ( !this.enabled || !this.hasFutureState() ) {
|
|
return;
|
|
}
|
|
|
|
this.breakpoint();
|
|
|
|
item = this.undoStack[this.undoStack.length - this.undoIndex];
|
|
if ( item ) {
|
|
// ve.copy( item.transactions ) invokes .clone() on each transaction in item.transactions
|
|
this.changeInternal( ve.copy( item.transactions ), item.selection, true );
|
|
this.undoIndex--;
|
|
this.emit( 'history' );
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Respond to transactions processed on the document by translating the selection and updating
|
|
* other state.
|
|
*
|
|
* @param {ve.dm.Transaction} tx Transaction that was processed
|
|
* @fires documentUpdate
|
|
*/
|
|
ve.dm.Surface.prototype.onDocumentTransact = function ( tx ) {
|
|
this.setSelection( tx.translateRange( this.selection ) );
|
|
this.emit( 'documentUpdate', tx );
|
|
};
|