mediawiki-extensions-Visual.../modules/ve/dm/ve.dm.Surface.js
Timo Tijhof 7db65f386c Rename @emits to @fires so we're forward compatible with JSDuck 5
Instead of using @emits in both, use our custom @fires in
production (JSDuck 4), and in the future it'll just naturally
use the native one.

This way we can also index oojs without issues, which seems to
have started using @fires already.

Change-Id: I7c3b56dd112626d57fa87ab995d205fb782a0149
2013-10-22 19:11:16 +00:00

514 lines
12 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.smallStack = [];
this.bigStack = [];
this.undoIndex = 0;
this.historyTrackingInterval = null;
this.insertionAnnotations = new ve.dm.AnnotationSet( this.documentModel.getStore() );
this.enabled = true;
};
/* Inheritance */
OO.mixinClass( ve.dm.Surface, OO.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;
}
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.smallStack = [];
this.bigStack = [];
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.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} 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.bigStack.length - this.undoIndex > 0 || !!this.smallStack.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.bigStack = this.bigStack.slice( 0, this.bigStack.length - this.undoIndex );
this.undoIndex = 0;
this.emit( 'history' );
}
};
/**
* 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
* @fires lock
* @fires select
* @fires transact
* @fires contextChange
* @fires change
* @fires unlock
*/
ve.dm.Surface.prototype.change = function ( transactions, selection ) {
if ( !this.enabled ) {
return;
}
var i, len, left, right, leftAnnotations, rightAnnotations, insertionAnnotations,
selectedNodes = {},
selectionChange = false,
contextChange = false,
dataModelData = this.documentModel.data;
// 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.truncateUndoStack();
this.smallStack.push( transactions[i] );
this.documentModel.commit( transactions[i] );
if ( !selection ) {
// translateRange only if selection is not provided because otherwise we are
// going to overwrite it
this.selection = transactions[i].translateRange( this.selection );
}
}
}
}
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 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;
}
// 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
* @fires history
* @returns {boolean} A breakpoint was added
*/
ve.dm.Surface.prototype.breakpoint = function ( selection ) {
if ( !this.enabled ) {
return false;
}
if ( this.smallStack.length > 0 ) {
this.bigStack.push( {
stack: this.smallStack,
selection: selection || this.selection.clone()
} );
this.smallStack = [];
this.emit( 'history' );
return true;
}
return false;
};
/**
* Step backwards in history.
*
* @method
* @fires lock
* @fires unlock
* @fires history
* @returns {ve.Range} Selection or null if no further state could be reached
*/
ve.dm.Surface.prototype.undo = function () {
if ( !this.enabled || !this.hasPastState() ) {
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].reversed();
selection = transaction.translateRange( selection );
this.documentModel.commit( transaction );
}
this.emit( 'unlock' );
this.emit( 'history' );
return selection;
}
return null;
};
/**
* Step forwards in history.
*
* @method
* @fires lock
* @fires unlock
* @fires history
* @returns {ve.Range} Selection or null if no further state could be reached
*/
ve.dm.Surface.prototype.redo = function () {
if ( !this.enabled || !this.hasFutureState() ) {
return;
}
var item, i, transaction, selection;
this.breakpoint();
if ( 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].clone();
this.documentModel.commit( transaction );
}
this.undoIndex--;
this.emit( 'unlock' );
this.emit( 'history' );
return selection;
}
return null;
};