Added ve.dm.Transaction.newFromRemoval

Also:
* Refactored tests
* Added tests for ve.dm.Transaction.newFromInsertion
* Added tests for ve.dm.Transaction.newFromRemoval
* Fixed problems with ve.dm.Transaction.newFromInsertion
* Added ve.dm.Node.canBeMergedWith which is partially a port of ve.Node.getCommonAncestorPaths merged with canMerge from within ve.dm.DocumentNode.prepareRemoval from the old ve codebase

Change-Id: Ibbc3887d08286d8ab33fd6296487802d65b319fa
This commit is contained in:
Trevor Parscal 2012-05-31 14:39:34 -07:00
parent 2f18605a1a
commit 32bddaf088
3 changed files with 349 additions and 46 deletions

View file

@ -160,6 +160,38 @@ ve.dm.Node.prototype.getAttribute = function( key ) {
return this.attributes[key];
};
/**
* Checks if this node can be merged with another.
*
* For two nodes to be mergeable, this node and the given node must either be the same node or:
* - Have the same type
* - Have the same depth
* - Have similar ancestory (each node upstream must have the same type)
*
* @method
* @param {ve.dm.Node} node Node to consider merging with
* @returns {Boolean} Nodes can be merged
*/
ve.dm.Node.prototype.canBeMergedWith = function( node ) {
var n1 = this,
n2 = node;
// Move up from n1 and n2 simultaneously until we find a common ancestor
while ( n1 !== n2 ) {
if (
// Check if we have reached a root (means there's no common ancestor or unequal depth)
( n1 === null || n2 === null ) ||
// Ensure that types match
n1.getType() !== n2.getType()
) {
return false;
}
// Move up
n1 = n1.getParent();
n2 = n2.getParent();
}
return true;
};
/* Inheritance */
ve.extendClass( ve.dm.Node, ve.Node );

View file

@ -11,24 +11,110 @@ ve.dm.Transaction = function() {
/* Static Methods */
ve.dm.Transaction.newFromInsertion = function( doc, offset, data ) {
var toInsert = doc.fixupInsertion( data, offset );
var tx = new ve.dm.Transaction();
tx.pushRetain( offset );
tx.pushReplace( [], toInsert );
/**
* 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
if ( offset ) {
tx.pushRetain( offset );
}
// Insert data
tx.pushReplace( [], insertion );
// Retain to end of document, if needed (for completeness)
if ( offset < data.length ) {
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 accross 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 ) {
// Implement me!
};
ve.dm.Transaction.newFromReplacement = function( doc, range, data ) {
// Implement me!
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, 'leaves' );
if ( selection.length === 0 ) {
// Empty selection? Something is wrong!
throw 'Invalid range, can not remove from ' + range.start + ' to ' + range.end;
}
// Decide whether to merge or strip
if ( selection[0].node.canBeMergedWith( selection[selection.length - 1].node ) ) {
// Retain to the start of the range
if ( range.start > 0 ) {
tx.pushRetain( range.start );
}
// Remove all data in a given range.
tx.pushReplace( data.slice( range.start, range.end ), [] );
// Retain up to the end of the document, if needed (for completeness)
if ( range.end < data.length ) {
tx.pushRetain( data.length - range.end );
}
} else {
var offset = 0,
nodeRange;
for ( var i = 0; i < selection.length; i++ ) {
nodeRange = selection[i].nodeRange;
// Retain up to where the next removal starts
if ( nodeRange.start > offset ) {
tx.pushRetain( nodeRange.start - offset );
}
// Remove data
if ( nodeRange.getLength() ) {
tx.pushReplace( data.slice( nodeRange.start, nodeRange.end ), [] );
}
// Advance to the next node
offset = nodeRange.end;
}
// Retain up to the end of the document, if needed (for completeness)
if ( offset < data.length ) {
tx.pushRetain( data.length - offset );
}
}
return tx;
};
/**
* Get a transaction that changes an attribute.
* Generates a transaction that changes an attribute.
*
* @static
* @method
@ -67,7 +153,7 @@ ve.dm.Transaction.newFromAttributeChange = function( doc, offset, key, value ) {
};
/**
* Get a transaction that annotates content.
* Generates a transaction that annotates content.
*
* @static
* @method

View file

@ -1,7 +1,200 @@
module( 've.dm.Transaction' );
/* Methods */
ve.dm.Transaction.runBuilderTests = function( cases ) {
for ( var msg in cases ) {
var tx = new ve.dm.Transaction();
for ( var i = 0; i < cases[msg].calls.length; i++ ) {
tx[cases[msg].calls[i][0]].apply( tx, cases[msg].calls[i].slice( 1 ) );
}
deepEqual( tx.getOperations(), cases[msg].ops, msg + ': operations match' );
deepEqual( tx.getLengthDifference(), cases[msg].diff, msg + ': length differences match' );
}
};
ve.dm.Transaction.runConstructorTests = function( constructor, cases ) {
for ( var msg in cases ) {
if ( cases[msg].ops ) {
var tx = constructor.apply(
ve.dm.Transaction, cases[msg].args
);
deepEqual( tx.getOperations(), cases[msg].ops, msg + ': operations match' );
} else if ( cases[msg].exception ) {
/*jshint loopfunc:true*/
raises( function() {
var tx = constructor.apply(
ve.dm.Transaction, cases[msg].args
);
}, cases[msg].exception, msg + ': throw exception' );
}
}
};
/* Tests */
test( 'newFromInsertion', function() {
var doc = new ve.dm.Document( ve.dm.example.data ),
cases = {
'content in first element': {
'args': [doc, 1, ['1', '2', '3']],
'ops': [
{ 'type': 'retain', 'length': 1 },
{
'type': 'replace',
'remove': [],
'insert': ['1', '2', '3']
},
{ 'type': 'retain', 'length': 58 }
]
},
'before first element': {
'args': [doc, 0, [{ 'type': 'paragraph' }, '1', { 'type': '/paragraph' }]],
'ops': [
{
'type': 'replace',
'remove': [],
'insert': [{ 'type': 'paragraph' }, '1', { 'type': '/paragraph' }]
},
{ 'type': 'retain', 'length': 59 }
]
},
'after last element': {
'args': [doc, 59, [{ 'type': 'paragraph' }, '1', { 'type': '/paragraph' }]],
'ops': [
{ 'type': 'retain', 'length': 59 },
{
'type': 'replace',
'remove': [],
'insert': [{ 'type': 'paragraph' }, '1', { 'type': '/paragraph' }]
}
]
},
'split paragraph': {
'args': [doc, 9, ['1', { 'type': '/paragraph' }, { 'type': 'paragraph' }]],
'ops': [
{ 'type': 'retain', 'length': 9 },
{
'type': 'replace',
'remove': [],
'insert': ['1', { 'type': '/paragraph' }, { 'type': 'paragraph' }]
},
{ 'type': 'retain', 'length': 50 }
]
}
};
ve.dm.Transaction.runConstructorTests( ve.dm.Transaction.newFromInsertion, cases );
} );
test( 'newFromRemoval', function() {
var doc = new ve.dm.Document( ve.dm.example.data ),
cases = {
'content in first element': {
'args': [doc, new ve.Range( 1, 3 )],
'ops': [
{ 'type': 'retain', 'length': 1 },
{
'type': 'replace',
'remove': [
'a',
['b', { '{"type":"textStyle/bold"}': { 'type': 'textStyle/bold' } }]
],
'insert': []
},
{ 'type': 'retain', 'length': 56 }
]
},
'content in last element': {
'args': [doc, new ve.Range( 57, 58 )],
'ops': [
{ 'type': 'retain', 'length': 57 },
{
'type': 'replace',
'remove': ['m'],
'insert': []
},
{ 'type': 'retain', 'length': 1 }
]
},
'first element': {
'args': [doc, new ve.Range( 0, 5 )],
'ops': [
{
'type': 'replace',
'remove': [
{ 'type': 'heading', 'attributes': { 'level': 1 } },
'a',
['b', { '{"type":"textStyle/bold"}': { 'type': 'textStyle/bold' } }],
['c', { '{"type":"textStyle/italic"}': { 'type': 'textStyle/italic' } }],
{ 'type': '/heading' }
],
'insert': []
},
{ 'type': 'retain', 'length': 54 }
]
},
'middle element with image': {
'args': [doc, new ve.Range( 36, 40 )],
'ops': [
{ 'type': 'retain', 'length': 36 },
{
'type': 'replace',
'remove': [
'h',
{ 'type': 'image', 'attributes': { 'html/src': 'image.png' } },
{ 'type': '/image' },
'i'
],
'insert': []
},
{ 'type': 'retain', 'length': 19 }
]
},
'last element': {
'args': [doc, new ve.Range( 56, 59 )],
'ops': [
{ 'type': 'retain', 'length': 56 },
{
'type': 'replace',
'remove': [{ 'type': 'paragraph' }, 'm', { 'type': '/paragraph' }],
'insert': []
}
]
},
'merge last two elements': {
'args': [doc, new ve.Range( 55, 57 )],
'ops': [
{ 'type': 'retain', 'length': 55 },
{
'type': 'replace',
'remove': [{ 'type': '/paragraph' }, { 'type': 'paragraph' }],
'insert': []
},
{ 'type': 'retain', 'length': 2 }
]
},
'strip out of paragraph in tableCell and paragraph in listItem': {
'args': [doc, new ve.Range( 9, 15 )],
'ops': [
{ 'type': 'retain', 'length': 9 },
{
'type': 'replace',
'remove': ['d'],
'insert': []
},
{ 'type': 'retain', 'length': 4 },
{
'type': 'replace',
'remove': ['e'],
'insert': []
},
{ 'type': 'retain', 'length': 44 }
]
}
};
ve.dm.Transaction.runConstructorTests( ve.dm.Transaction.newFromRemoval, cases );
} );
test( 'newFromAttributeChange', function() {
var doc = new ve.dm.Document( ve.dm.example.data ),
cases = {
@ -39,21 +232,7 @@ test( 'newFromAttributeChange', function() {
'exception': /^Can not set attributes on closing element$/
}
};
for ( var msg in cases ) {
if ( cases[msg].ops ) {
var tx = ve.dm.Transaction.newFromAttributeChange.apply(
ve.dm.Transaction, cases[msg].args
);
deepEqual( tx.getOperations(), cases[msg].ops, msg + ': operations match' );
} else if ( cases[msg].exception ) {
/*jshint loopfunc:true*/
raises( function() {
var tx = ve.dm.Transaction.newFromAttributeChange.apply(
ve.dm.Transaction, cases[msg].args
);
}, cases[msg].exception, msg + ': throw exception' );
}
}
ve.dm.Transaction.runConstructorTests( ve.dm.Transaction.newFromAttributeChange, cases );
} );
test( 'newFromAnnotation', function() {
@ -154,15 +333,10 @@ test( 'newFromAnnotation', function() {
]
}
};
for ( var msg in cases ) {
var tx = ve.dm.Transaction.newFromAnnotation.apply(
ve.dm.Transaction, cases[msg].args
);
deepEqual( tx.getOperations(), cases[msg].ops, msg + ': operations match' );
}
ve.dm.Transaction.runConstructorTests( ve.dm.Transaction.newFromAnnotation, cases );
} );
test( 'constructor', function() {
test( 'pushRetain', function() {
var cases = {
'retain': {
'calls': [['pushRetain', 5]],
@ -173,10 +347,16 @@ test( 'constructor', function() {
'calls': [['pushRetain', 5], ['pushRetain', 3]],
'ops': [{ 'type': 'retain', 'length': 8 }],
'diff': 0
},
}
};
ve.dm.Transaction.runBuilderTests( cases );
} );
test( 'pushReplace', function() {
var cases = {
'insert': {
'calls': [
['pushReplace', [], [{ 'type': 'paragraph' }, 'a', 'b', 'c', { 'type': '/paragraph' }]]
['pushReplace', [], [{ 'type': 'paragraph' }, 'a', 'b', 'c', { 'type': '/paragraph' }]]
],
'ops': [
{
@ -291,7 +471,13 @@ test( 'constructor', function() {
}
],
'diff': 0
},
}
};
ve.dm.Transaction.runBuilderTests( cases );
} );
test( 'pushReplaceElementAttribute', function() {
var cases = {
'replace element attribute': {
'calls': [
['pushReplaceElementAttribute', 'style', 'bullet', 'number']
@ -326,7 +512,13 @@ test( 'constructor', function() {
}
],
'diff': 0
},
}
};
ve.dm.Transaction.runBuilderTests( cases );
} );
test( 'push*Annotating', function() {
var cases = {
'start annotating': {
'calls': [
['pushStartAnnotating', 'set', { 'type': 'textStyle/bold' }]
@ -398,12 +590,5 @@ test( 'constructor', function() {
'diff': 0
}
};
for ( var msg in cases ) {
var tx = new ve.dm.Transaction();
for ( var i = 0; i < cases[msg].calls.length; i++ ) {
tx[cases[msg].calls[i][0]].apply( tx, cases[msg].calls[i].slice( 1 ) );
}
deepEqual( tx.getOperations(), cases[msg].ops, msg + ': operations match' );
deepEqual( tx.getLengthDifference(), cases[msg].diff, msg + ': length differences match' );
}
ve.dm.Transaction.runBuilderTests( cases );
} );