mediawiki-extensions-Visual.../modules/ve/dm/ve.dm.Surface.js
Ed Sanders 8b09dd7650 The resurrection
By removing the transaction listeners from surface fragments we
no longer have to make sure they are always manually destroyed.

In order to retain the functionality of having fragments update
with transactions elsewhere we keep a pointer to a place in the
new complete history stack in the surface. The complete history
stack records all transactions, even undone ones.

Whenever getRange is called we replay all transactions in the
complete history (in the correct order) since the fragment was
last updated.

Also in this commit:
* Updated Format/IndentationAction to test undo(). This increases
  coverage of surface fragment behaviour.
* .range is always accessed by .getRange now, although as an
  optimisation we can use the noCopy mode when we a sure the
  returned range will not be modified.
* Added undo test to .update (previously .onTransact)

Bug: 47343
Change-Id: I9e9818da1baa8319a3002f6d74fd1aad6732a8f5
2013-04-22 12:50:23 +01:00

485 lines
11 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
* @extends ve.EventEmitter
* @constructor
* @param {ve.dm.Document} doc Document model to create surface for
*/
ve.dm.Surface = function VeDmSurface( doc ) {
// Parent constructor
ve.EventEmitter.call( this );
// Properties
this.documentModel = doc;
this.metaList = new ve.dm.MetaList( this );
this.selection = new ve.Range( 0, 0 );
this.selectedNodes = {};
this.smallStack = [];
this.bigStack = [];
this.completeHistory = [];
this.undoIndex = 0;
this.historyTrackingInterval = null;
this.insertionAnnotations = new ve.dm.AnnotationSet( this.documentModel.getStore() );
this.enabled = true;
};
/* Inheritance */
ve.inheritClass( ve.dm.Surface, ve.EventEmitter );
/* Events */
/**
* @event lock
*/
/**
* @event unlock
*/
/**
* @event select
* @param {ve.ui.MenuItemWidget} item Menu item
*/
/**
* @event transact
* @param {ve.dm.Transaction[]} transactions Transactions that have just been processed
*/
/**
* @event contextChange
*/
/**
* @event change
* @see #method-change
* @param {ve.dm.Transaction|null} transaction
* @param {ve.Range|undefined} selection
*/
/**
* @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;
}
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;
}
clearInterval( this.historyTrackingInterval );
};
/**
* Remove all states from history.
*
* @method
*/
ve.dm.Surface.prototype.purgeHistory = function () {
if ( !this.enabled ) {
return;
}
this.selection = null;
this.smallStack = [];
this.bigStack = [];
this.undoIndex = 0;
};
/**
* Get a list of all history states.
*
* @method
* @returns {Array[]} List of transaction stacks
*/
ve.dm.Surface.prototype.getHistory = function () {
if ( this.smallStack.length > 0 ) {
return this.bigStack.slice( 0 ).concat( [{ 'stack': this.smallStack.slice(0) }] );
} else {
return this.bigStack.slice( 0 );
}
};
/**
* Get annotations that will be used upon insertion.
*
* @method
* @returns {ve.dm.AnnotationSet|null} Insertion anotations or null if not being used
*/
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
* @emits contextChange
*/
ve.dm.Surface.prototype.setInsertionAnnotations = function ( annotations ) {
if ( !this.enabled ) {
return;
}
this.insertionAnnotations = annotations.clone();
this.emit( 'contextChange' );
};
/**
* Add an annotation to be used upon insertion.
*
* @method
* @param {ve.dm.AnnotationSet} Insertion anotation to add
* @emits contextChange
*/
ve.dm.Surface.prototype.addInsertionAnnotation = function ( annotation ) {
if ( !this.enabled ) {
return;
}
this.insertionAnnotations.push( annotation );
this.emit( 'contextChange' );
};
/**
* Remove an annotation from those that will be used upon insertion.
*
* @method
* @param {ve.dm.AnnotationSet} Insertion anotation to remove
* @emits contextChange
*/
ve.dm.Surface.prototype.removeInsertionAnnotation = function ( annotation ) {
if ( !this.enabled ) {
return;
}
this.insertionAnnotations.remove( annotation );
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.bigStack.length - this.undoIndex > 0;
};
/**
* Get the document model.
*
* @method
* @returns {ve.dm.DocumentNode} Document model of the surface
*/
ve.dm.Surface.prototype.getDocument = function () {
return this.documentModel;
};
/**
* 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 );
};
/**
* 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|undefined} selection
* @emits lock
* @emits select
* @emits transact
* @emits contextChange
* @emits change
* @emits unlock
*/
ve.dm.Surface.prototype.change = function ( transactions, selection ) {
if ( !this.enabled ) {
return;
}
var i, len, offset, annotations,
selectedNodes = {},
selectionChange = false,
contextChange = false;
// Stop observation polling, things changing right now are known already
this.emit( 'lock' );
// Process transactions and apply selection changes
if ( transactions ) {
if ( transactions instanceof ve.dm.Transaction ) {
transactions = [transactions];
}
for ( i = 0, len = transactions.length; i < len; i++ ) {
if ( !transactions[i].isNoOp() ) {
this.bigStack = this.bigStack.slice( 0, this.bigStack.length - this.undoIndex );
this.undoIndex = 0;
this.smallStack.push( transactions[i] );
this.completeHistory.push( {
'undo': false,
'transaction': transactions[i]
} );
this.documentModel.commit( transactions[i] );
}
}
}
if ( selection ) {
// Detect if selection range changed
if ( !this.selection || !this.selection.equals( selection ) ) {
selectionChange = true;
}
// 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;
}
this.selectedNodes = selectedNodes;
if ( selectionChange ) {
this.emit( 'select', this.selection.clone() );
}
this.selection = selection;
}
// Only emit a transact event if transactions were actually processed
if ( transactions ) {
this.emit( 'transact', transactions );
// Detect context change, if not detected already, when element attributes have changed
if ( !contextChange ) {
for ( i = 0, len = transactions.length; i < len; i++ ) {
if ( transactions[i].hasElementAttributeOperations() ) {
contextChange = true;
break;
}
}
}
}
// Figure out which offset which we should get insertion annotations from
if ( this.selection.isCollapsed() ) {
// Get annotations from the left of the cursor
offset = this.documentModel.data.getNearestContentOffset(
Math.max( 0, this.selection.start - 1 ), -1
);
} else {
// Get annotations from the first character of the selection
offset = this.documentModel.data.getNearestContentOffset( this.selection.start );
}
if ( offset === -1 ) {
// Document is empty, use empty set
annotations = new ve.dm.AnnotationSet( this.documentModel.getStore() );
} else {
annotations = this.documentModel.data.getAnnotationsFromOffset( offset );
}
// Only emit an annotations change event if there's a meaningful difference
if (
!annotations.containsAllOf( this.insertionAnnotations ) ||
!this.insertionAnnotations.containsAllOf( annotations )
) {
this.insertionAnnotations = annotations;
contextChange = true;
}
// Only emit one context change event
if ( contextChange ) {
this.emit( 'contextChange' );
}
this.emit( 'change', transactions, selection );
// Continue observation polling, we want to know about things that change from here on out
this.emit( 'unlock' );
};
/**
* Set a history state breakpoint.
*
* @method
* @param {ve.Range} selection New selection range
* @emits history
*/
ve.dm.Surface.prototype.breakpoint = function ( selection ) {
if ( !this.enabled ) {
return;
}
if ( this.smallStack.length > 0 ) {
this.bigStack.push( {
stack: this.smallStack,
selection: selection || this.selection.clone()
} );
this.smallStack = [];
this.emit( 'history' );
}
};
/**
* Step backwards in history.
*
* @method
* @see ve.dm.Document#rollback
* @emits lock
* @emits unlock
* @emits history
* @returns {ve.Range} Selection or null if no further state could be reached
*/
ve.dm.Surface.prototype.undo = function () {
if ( !this.enabled ) {
return;
}
var item, i, transaction, selection;
this.breakpoint();
this.undoIndex++;
if ( this.bigStack[this.bigStack.length - this.undoIndex] ) {
this.emit( 'lock' );
item = this.bigStack[this.bigStack.length - this.undoIndex];
selection = item.selection;
for ( i = item.stack.length - 1; i >= 0; i-- ) {
transaction = item.stack[i];
selection = transaction.translateRange( selection, true );
this.completeHistory.push( {
'undo': true,
'transaction': transaction
} );
this.documentModel.rollback( transaction );
}
this.emit( 'unlock' );
this.emit( 'history' );
return selection;
}
return null;
};
/**
* Get the length of the complete history stack. This is also the current pointer.
* @returns {number} Length of the complete history stack
*/
ve.dm.Surface.prototype.getCompleteHistoryLength = function () {
return this.completeHistory.length;
};
/**
* Get all the items in the complete history stack since a specified pointer.
* @param {number} pointer Pointer from where to start the slice
* @returns {Array} Array of transaction objects with undo flag
*/
ve.dm.Surface.prototype.getCompleteHistorySince = function ( pointer ) {
return this.completeHistory.slice( pointer );
};
/**
* Step forwards in history.
*
* @method
* @see ve.dm.Document#commit
* @emits lock
* @emits unlock
* @emits history
* @returns {ve.Range} Selection or null if no further state could be reached
*/
ve.dm.Surface.prototype.redo = function () {
if ( !this.enabled ) {
return;
}
var item, i, transaction, selection;
this.breakpoint();
if ( this.undoIndex > 0 && this.bigStack[this.bigStack.length - this.undoIndex] ) {
this.emit( 'lock' );
item = this.bigStack[this.bigStack.length - this.undoIndex];
selection = item.selection;
for ( i = 0; i < item.stack.length; i++ ) {
transaction = item.stack[i];
this.completeHistory.push( {
'undo': false,
'transaction': transaction
} );
this.documentModel.commit( transaction );
}
this.undoIndex--;
this.emit( 'unlock' );
this.emit( 'history' );
return selection;
}
return null;
};