mediawiki-extensions-Visual.../modules/ve2/dm/ve.dm.Transaction.js
Trevor Parscal c8c4ed8f99 Added ve.dm.Transaction.newFromContentBranchConversion
Change-Id: I0e272b24dddae028e44d4c693297b1875000ef57
2012-06-11 15:58:44 -07:00

412 lines
12 KiB
JavaScript

/**
* DataModel transaction.
*
* @class
* @constructor
*/
ve.dm.Transaction = function() {
this.operations = [];
this.lengthDifference = 0;
};
/* Static Methods */
/**
* Generates a transaction that inserts data at a given offset.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {Integer} offset Offset to insert at
* @param {Array} data Data to insert
* @returns {ve.dm.Transaction} Transcation that inserts data
*/
ve.dm.Transaction.newFromInsertion = function( doc, offset, insertion ) {
var tx = new ve.dm.Transaction(),
data = doc.getData();
// Fix up the insertion
insertion = doc.fixupInsertion( insertion, offset );
// Retain up to insertion point, if needed
tx.pushRetain( offset );
// Insert data
tx.pushReplace( [], insertion );
// Retain to end of document, if needed (for completeness)
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generates a transaction which removes data from a given range.
*
* There are three possible results from a removal:
* 1. Remove content only
* - Occurs when the range starts and ends on elements of different type, depth or ancestry
* 2. Remove entire elements and their content
* - Occurs when the range spans across an entire element
* 3. Merge two elements by removing the end of one and the beginning of another
* - Occurs when the range starts and ends on elements of similar type, depth and ancestry
*
* This function uses the following logic to decide what to actually remove:
* 1. Elements are only removed if range being removed covers the entire element
* 2. Elements can only be merged if ve.dm.Node.canBeMergedWith() returns true
* 3. Merges take place at the highest common ancestor
*
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {ve.Range} range Range of data to remove
* @returns {ve.dm.Transaction} Transcation that removes data
* @throws 'Invalid range, can not remove from {range.start} to {range.end}'
*/
ve.dm.Transaction.newFromRemoval = function( doc, range ) {
var tx = new ve.dm.Transaction(),
data = doc.getData();
// Normalize and validate range
range.normalize();
if ( range.start === range.end ) {
// Empty range, nothing to remove, retain up to the end of the document (for completeness)
tx.pushRetain( data.length );
return tx;
}
// Select nodes and validate selection
var selection = doc.selectNodes( range, 'covered' );
if ( selection.length === 0 ) {
// Empty selection? Something is wrong!
throw 'Invalid range, cannot remove from ' + range.start + ' to ' + range.end;
}
var first, last, offset = 0, removeStart = null, removeEnd = null, nodeStart, nodeEnd;
first = selection[0];
last = selection[selection.length - 1];
// If the first and last node are mergeable, merge them
if ( first.node.canBeMergedWith( last.node ) ) {
if ( !first.range && !last.range ) {
// First and last node are both completely covered, remove them
removeStart = first.nodeOuterRange.start;
removeEnd = last.nodeOuterRange.end;
} else {
// Either the first node or the last node is partially covered, so remove
// the selected content
removeStart = ( first.range || first.nodeRange ).start;
removeEnd = ( last.range || last.nodeRange ).end;
}
tx.pushRetain( removeStart );
tx.pushReplace( data.slice( removeStart, removeEnd ), [] );
tx.pushRetain( data.length - removeEnd );
// All done
return tx;
}
// The selection wasn't mergeable, so remove nodes that are completely covered, and strip
// nodes that aren't
for ( i = 0; i < selection.length; i++ ) {
if ( !selection[i].range ) {
// Entire node is covered, remove it
nodeStart = selection[i].nodeOuterRange.start;
nodeEnd = selection[i].nodeOuterRange.end;
} else {
// Part of the node is covered, remove that range
nodeStart = selection[i].range.start;
nodeEnd = selection[i].range.end;
}
// Merge contiguous removals. Only apply a removal when a gap appears, or at the
// end of the loop
if ( removeEnd === null ) {
// First removal
removeStart = nodeStart;
removeEnd = nodeEnd;
} else if ( removeEnd === nodeStart ) {
// Merge this removal into the previous one
removeEnd = nodeEnd;
} else {
// There is a gap between the previous removal and this one
// Push the previous removal first
tx.pushRetain( removeStart - offset );
tx.pushReplace( data.slice( removeStart, removeEnd ), [] );
offset = removeEnd;
// Now start this removal
removeStart = nodeStart;
removeEnd = nodeEnd;
}
}
// Apply the last removal, if any
if ( removeEnd !== null ) {
tx.pushRetain( removeStart - offset );
tx.pushReplace( data.slice( removeStart, removeEnd ), [] );
offset = removeEnd;
}
// Retain up to the end of the document
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generates a transaction that changes an attribute.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {Integer} offset Offset of element
* @param {String} key Attribute name
* @param {Mixed} value New value
* @returns {ve.dm.Transaction} Transcation that changes an element
* @throws 'Can not set attributes to non-element data'
* @throws 'Can not set attributes on closing element'
*/
ve.dm.Transaction.newFromAttributeChange = function( doc, offset, key, value ) {
var tx = new ve.dm.Transaction(),
data = doc.getData();
// Verify element exists at offset
if ( data[offset].type === undefined ) {
throw 'Can not set attributes to non-element data';
}
// Verify element is not a closing
if ( data[offset].type.charAt( 0 ) === '/' ) {
throw 'Can not set attributes on closing element';
}
// Retain up to element
tx.pushRetain( offset );
// Change attribute
tx.pushReplaceElementAttribute(
key, 'attributes' in data[offset] ? data[offset].attributes[key] : undefined, value
);
// Retain to end of document
tx.pushRetain( data.length - offset );
return tx;
};
/**
* Generates a transaction that annotates content.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {ve.Range} range Range to annotate
* @param {String} method Annotation mode
* 'set': Adds annotation to all content in range
* 'clear': Removes instances of annotation from content in range
* @param {Object} annotation Annotation to set or clear
* @returns {ve.dm.Transaction} Transcation that annotates content
*/
ve.dm.Transaction.newFromAnnotation = function( doc, range, method, annotation ) {
var tx = new ve.dm.Transaction(),
data = doc.getData(),
hash = ve.getHash( annotation );
// Iterate over all data in range, annotating where appropriate
range.normalize();
var i = range.start,
span = i,
on = false;
while ( i < range.end ) {
if ( data[i].type !== undefined ) {
// Element
if ( on ) {
tx.pushRetain( span );
tx.pushStopAnnotating( method, annotation );
span = 0;
on = false;
}
} else {
// Content
var covered = doc.offsetContainsAnnotation( i, annotation );
if ( ( covered && method === 'set' ) || ( !covered && method === 'clear' ) ) {
// Skip annotated content
if ( on ) {
tx.pushRetain( span );
tx.pushStopAnnotating( method, annotation );
span = 0;
on = false;
}
} else {
// Cover non-annotated content
if ( !on ) {
tx.pushRetain( span );
tx.pushStartAnnotating( method, annotation );
span = 0;
on = true;
}
}
}
span++;
i++;
}
tx.pushRetain( span );
if ( on ) {
tx.pushStopAnnotating( method, annotation );
}
tx.pushRetain( data.length - range.end );
return tx;
};
/**
* Generates a transaction that converts elements that can contain content.
*
* @static
* @method
* @param {ve.dm.Document} doc Document to create transaction for
* @param {ve.Range} range Range to convert
* @param {String} type Symbolic name of element type to convert to
* @param {Object} attr Attributes to initialize element with
* @returns {ve.dm.Transaction} Transcation that annotates content
*/
ve.dm.Transaction.newFromContentBranchConversion = function( doc, range, type, attr ) {
var tx = new ve.dm.Transaction(),
data = doc.getData(),
selection = doc.selectNodes( range, 'leaves' ),
opening = { 'type': type },
closing = { 'type': '/' + type },
previousBranch,
previousBranchOuterRange;
// Add attributes to opening if needed
if ( ve.isPlainObject( attr ) ) {
opening.attributes = attr;
}
// Replace the wrappings of each content branch in the range
for ( var i = 0; i < selection.length; i++ ) {
var selected = selection[i];
if ( selected.node.isContent() ) {
var branch = selected.node.getParent(),
branchOuterRange = branch.getOuterRange();
// Don't convert the same branch twice
if ( branch === previousBranch ) {
continue;
}
// Retain up to this branch, considering where the previous one left off
tx.pushRetain(
branchOuterRange.start - ( previousBranch ? previousBranchOuterRange.end : 0 )
);
// Replace the opening
tx.pushReplace( [data[branchOuterRange.start]], [ve.copyObject( opening )] );
// Retain the contents
tx.pushRetain( branch.getLength() );
// Replace the closing
tx.pushReplace( [data[branchOuterRange.end - 1]], [ve.copyObject( closing )] );
// Remember this branch and it's range for next time
previousBranch = branch;
previousBranchOuterRange = branchOuterRange;
}
}
// Retain until the end
tx.pushRetain(
data.length - ( previousBranch ? previousBranchOuterRange.end : 0 )
);
return tx;
};
/* Methods */
/**
* Gets a list of all operations.
*
* @method
* @returns {Object[]} List of operations
*/
ve.dm.Transaction.prototype.getOperations = function() {
return this.operations;
};
/**
* Gets the difference in content length this transaction will cause if applied.
*
* @method
* @returns {Integer} Difference in content length
*/
ve.dm.Transaction.prototype.getLengthDifference = function() {
return this.lengthDifference;
};
/**
* Adds a retain operation.
*
* @method
* @param {Integer} length Length of content data to retain
* @throws 'Invalid retain length, can not retain backwards: {length}'
*/
ve.dm.Transaction.prototype.pushRetain = function( length ) {
if ( length < 0 ) {
throw 'Invalid retain length, can not retain backwards:' + length;
}
if ( length ) {
var end = this.operations.length - 1;
if ( this.operations.length && this.operations[end].type === 'retain' ) {
this.operations[end].length += length;
} else {
this.operations.push( {
'type': 'retain',
'length': length
} );
}
}
};
/**
* Adds a replace operation
*
* @method
* @param {Array} remove Data to remove
* @param {Array] insert Data to replace 'remove' with
*/
ve.dm.Transaction.prototype.pushReplace = function( remove, insert ) {
if ( remove.length === 0 && insert.length === 0 ) {
// Don't push no-ops
return;
}
this.operations.push( {
'type': 'replace',
'remove': remove,
'insert': insert
} );
this.lengthDifference += insert.length - remove.length;
};
/**
* Adds an element attribute change operation.
*
* @method
* @param {String} key Name of attribute to change
* @param {Mixed} from Value change attribute from
* @param {Mixed} to Value to change attribute to
*/
ve.dm.Transaction.prototype.pushReplaceElementAttribute = function( key, from, to ) {
this.operations.push( {
'type': 'attribute',
'key': key,
'from': from,
'to': to
} );
};
/**
* Adds a start annotating operation.
*
* @method
* @param {String} method Method to use, either "set" or "clear"
* @param {Object} annotation Annotation object to start setting or clearing from content data
*/
ve.dm.Transaction.prototype.pushStartAnnotating = function( method, annotation ) {
this.operations.push( {
'type': 'annotate',
'method': method,
'bias': 'start',
'annotation': annotation
} );
};
/**
* Adds a stop annotating operation.
*
* @method
* @param {String} method Method to use, either "set" or "clear"
* @param {Object} annotation Annotation object to stop setting or clearing from content data
*/
ve.dm.Transaction.prototype.pushStopAnnotating = function( method, annotation ) {
this.operations.push( {
'type': 'annotate',
'method': method,
'bias': 'stop',
'annotation': annotation
} );
};