From 32bddaf08874de245e6aaa15b6f4536d2ffae8e0 Mon Sep 17 00:00:00 2001 From: Trevor Parscal Date: Thu, 31 May 2012 14:39:34 -0700 Subject: [PATCH] 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 --- modules/ve2/dm/ve.dm.Node.js | 32 ++++ modules/ve2/dm/ve.dm.Transaction.js | 110 +++++++++-- tests/ve2/dm/ve.dm.Transaction.test.js | 253 +++++++++++++++++++++---- 3 files changed, 349 insertions(+), 46 deletions(-) diff --git a/modules/ve2/dm/ve.dm.Node.js b/modules/ve2/dm/ve.dm.Node.js index be5ca7473c..ea4cb2e4bb 100644 --- a/modules/ve2/dm/ve.dm.Node.js +++ b/modules/ve2/dm/ve.dm.Node.js @@ -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 ); diff --git a/modules/ve2/dm/ve.dm.Transaction.js b/modules/ve2/dm/ve.dm.Transaction.js index 0804ba6b6c..c2a49ed07d 100644 --- a/modules/ve2/dm/ve.dm.Transaction.js +++ b/modules/ve2/dm/ve.dm.Transaction.js @@ -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 diff --git a/tests/ve2/dm/ve.dm.Transaction.test.js b/tests/ve2/dm/ve.dm.Transaction.test.js index cb2653c42a..83ff99197a 100644 --- a/tests/ve2/dm/ve.dm.Transaction.test.js +++ b/tests/ve2/dm/ve.dm.Transaction.test.js @@ -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 ); } );