diff --git a/modules/ve/dm/ve.dm.Document.js b/modules/ve/dm/ve.dm.Document.js index 54e0848549..c03ef0755e 100644 --- a/modules/ve/dm/ve.dm.Document.js +++ b/modules/ve/dm/ve.dm.Document.js @@ -426,6 +426,29 @@ ve.dm.Document.isContentData = function ( data ) { return true; }; +/** + * Get a slice or copy of the provided data. + * + * @static + * @method + * @param {Array} sourceData Source data to slice up + * @param {ve.Range} [range] Range of data to get, all data will be given by default + * @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow) + * @returns {Array} Slice or copy of document data + */ +ve.dm.Document.getDataSlice = function ( sourceData, range, deep ) { + var end, data, + start = 0; + if ( range !== undefined ) { + start = Math.max( 0, Math.min( sourceData.length, range.start ) ); + end = Math.max( 0, Math.min( sourceData.length, range.end ) ); + } + // IE work-around: arr.slice( 0, undefined ) returns [] while arr.slice( 0 ) behaves correctly + data = end === undefined ? sourceData.slice( start ) : sourceData.slice( start, end ); + // Return either the slice or a deep copy of the slice + return deep ? ve.copyArray( data ) : data; +}; + /* Methods */ /** @@ -457,16 +480,19 @@ ve.dm.Document.prototype.commit = function ( transaction ) { * @returns {Array} Slice or copy of document data */ ve.dm.Document.prototype.getData = function ( range, deep ) { - var end, data, - start = 0; - if ( range !== undefined ) { - start = Math.max( 0, Math.min( this.data.length, range.start ) ); - end = Math.max( 0, Math.min( this.data.length, range.end ) ); - } - // IE work-around: arr.slice( 0, undefined ) returns [] while arr.slice( 0 ) behaves correctly - data = end === undefined ? this.data.slice( start ) : this.data.slice( start, end ); - // Return either the slice or a deep copy of the slice - return deep ? ve.copyArray( data ) : data; + return this.constructor.getDataSlice( this.data, range, deep ); +}; + +/** + * Get a slice or copy of the document metadata. + * + * @method + * @param {ve.Range} [range] Range of metadata to get, all metadata will be given by default + * @param {boolean} [deep=false] Whether to return a deep copy (WARNING! This may be very slow) + * @returns {Array} Slice or copy of document metadata + */ +ve.dm.Document.prototype.getMetadata = function ( range, deep ) { + return this.constructor.getDataSlice( this.metadata, range, deep ); }; /** @@ -515,6 +541,27 @@ ve.dm.Document.prototype.spliceData = function ( offset, remove, insert ) { return spliced; }; +/** + * Splice metadata into and/or out of the linear model. + * + * `this.metadata` will be updated accordingly. + * + * @method + * @see ve#batchSplice + * @param offset + * @param index + * @param remove + * @param insert + */ +ve.dm.Document.prototype.spliceMetadata = function ( offset, index, remove, insert ) { + var elements = this.metadata[offset]; + if ( !elements ) { + this.metadata[offset] = elements = []; + } + insert = insert || []; + return ve.batchSplice( elements, index, remove, insert ); +}; + /** * Get the full document data including metadata. * diff --git a/modules/ve/dm/ve.dm.Transaction.js b/modules/ve/dm/ve.dm.Transaction.js index fee5e0a774..15bb450b91 100644 --- a/modules/ve/dm/ve.dm.Transaction.js +++ b/modules/ve/dm/ve.dm.Transaction.js @@ -28,7 +28,7 @@ ve.dm.Transaction = function VeDmTransaction() { * @param {ve.dm.Document} doc Document to create transaction for * @param {number} offset Offset to insert at * @param {Array} data Data to insert - * @returns {ve.dm.Transaction} Transcation that inserts data + * @returns {ve.dm.Transaction} Transaction that inserts data */ ve.dm.Transaction.newFromInsertion = function ( doc, offset, insertion ) { var tx = new ve.dm.Transaction(), @@ -65,7 +65,7 @@ ve.dm.Transaction.newFromInsertion = function ( doc, offset, insertion ) { * @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 + * @returns {ve.dm.Transaction} Transaction that removes data * @throws {Error} Invalid range */ ve.dm.Transaction.newFromRemoval = function ( doc, range ) { @@ -163,7 +163,7 @@ ve.dm.Transaction.newFromRemoval = function ( doc, range ) { * @param {number} offset Offset of element * @param {string} key Attribute name * @param {Mixed} value New value, or undefined to remove the attribute - * @returns {ve.dm.Transaction} Transcation that changes an element + * @returns {ve.dm.Transaction} Transaction that changes an element * @throws {Error} Cannot set attributes to non-element data * @throws {Error} Cannot set attributes on closing element */ @@ -255,6 +255,121 @@ ve.dm.Transaction.newFromAnnotation = function ( doc, range, method, annotation return tx; }; + +/** + * Generate a transaction that inserts metadata elements. + * + * @static + * @method + * @param {ve.dm.Document} doc Document to create transaction for + * @param {number} offset Offset of element + * @param {number} index Index of metadata cursor within element + * @param {Array} newElements New elements to insert + * @returns {ve.dm.Transaction} Transaction that inserts the metadata elements + */ +ve.dm.Transaction.newFromMetadataInsertion = function ( doc, offset, index, newElements ) { + var tx = new ve.dm.Transaction(), + data = doc.getMetadata(), + elements = data[offset] || []; + + // Retain up to element + tx.pushRetain( offset ); + // Retain up to metadata element (second dimension) + tx.pushRetainMetadata( index ); + // Insert metadata elements + tx.pushReplaceMetadata( + [], newElements + ); + // Retain up to end of metadata elements (second dimension) + tx.pushRetainMetadata( elements.length - index ); + // Retain to end of document + tx.pushRetain( data.length - offset ); + return tx; +}; + + +/** + * Generate a transaction that removes metadata elements. + * + * @static + * @method + * @param {ve.dm.Document} doc Document to create transaction for + * @param {number} offset Offset of element + * @param {ve.Range} range Range of metadata to remove + * @returns {ve.dm.Transaction} Transaction that removes data + * @throws {Error} Cannot remove metadata from empty list + * @throws {Error} Range out of bounds + */ +ve.dm.Transaction.newFromMetadataRemoval = function ( doc, offset, range ) { + var selection, + tx = new ve.dm.Transaction(), + data = doc.getMetadata(), + elements = data[offset] || []; + + if ( !elements.length ) { + throw new Error( 'Cannot remove metadata from empty list' ); + } + + if ( range.start < 0 || range.end > elements.length ) { + throw new Error( 'Range out of bounds' ); + } + + selection = elements.slice( range.start, range.end ); + + // Retain up to element + tx.pushRetain( offset ); + // Retain up to metadata element (second dimension) + tx.pushRetainMetadata( range.start ); + // Remove metadata elements + tx.pushReplaceMetadata( + selection, [] + ); + // Retain up to end of metadata elements (second dimension) + tx.pushRetainMetadata( elements.length - range.end ); + // Retain to end of document + tx.pushRetain( data.length - offset ); + return tx; +}; + +/** + * Generate a transaction that relaces a single metadata element. + * + * @static + * @method + * @param {ve.dm.Document} doc Document to create transaction for + * @param {number} offset Offset of element + * @param {number} index Index of metadata cursor within element + * @param {Object} newElement New element to insert + * @returns {ve.dm.Transaction} Transaction that removes data + * @throws {Error} Metadata index out of bounds + */ +ve.dm.Transaction.newFromMetadataElementReplacement = function ( doc, offset, index, newElement ) { + var oldElement, + tx = new ve.dm.Transaction(), + data = doc.getMetadata(), + elements = data[offset] || []; + + if ( index >= elements.length ) { + throw new Error( 'Metadata index out of bounds' ); + } + + oldElement = elements[index]; + + // Retain up to element + tx.pushRetain( offset ); + // Retain up to metadata element (second dimension) + tx.pushRetainMetadata( index ); + // Remove metadata elements + tx.pushReplaceMetadata( + [ oldElement ], [ newElement ] + ); + // Retain up to end of metadata elements (second dimension) + tx.pushRetainMetadata( elements.length - index - 1 ); + // Retain to end of document + tx.pushRetain( data.length - offset ); + return tx; +}; + /** * Generate a transaction that converts elements that can contain content. * @@ -636,6 +751,31 @@ ve.dm.Transaction.prototype.pushRetain = function ( length ) { } }; +/** + * Add a retain metadata operation. + * // TODO: this is a copy/paste of pushRetain (at the moment). Consider a refactor. + * + * @method + * @param {number} length Length of content data to retain + * @throws {Error} Cannot retain backwards. + */ +ve.dm.Transaction.prototype.pushRetainMetadata = function ( length ) { + if ( length < 0 ) { + throw new Error( 'Invalid retain length, cannot retain backwards:' + length ); + } + if ( length ) { + var end = this.operations.length - 1; + if ( this.operations.length && this.operations[end].type === 'retainMetadata' ) { + this.operations[end].length += length; + } else { + this.operations.push( { + 'type': 'retainMetadata', + 'length': length + } ); + } + } +}; + /** * Add a replace operation * @@ -656,6 +796,26 @@ ve.dm.Transaction.prototype.pushReplace = function ( remove, insert ) { this.lengthDifference += insert.length - remove.length; }; +/** + * Add a replace metadata operation + * // TODO: this is a copy/paste of pushRetainMetadata (at the moment). Consider a refactor. + * + * @method + * @param {Array} remove Metadata to remove + * @param {Array} insert Metadata to replace 'remove' with + */ +ve.dm.Transaction.prototype.pushReplaceMetadata = function ( remove, insert ) { + if ( remove.length === 0 && insert.length === 0 ) { + // Don't push no-ops + return; + } + this.operations.push( { + 'type': 'replaceMetadata', + 'remove': remove, + 'insert': insert + } ); +}; + /** * Add an element attribute change operation. * diff --git a/modules/ve/dm/ve.dm.TransactionProcessor.js b/modules/ve/dm/ve.dm.TransactionProcessor.js index 721f767319..5682e819b1 100644 --- a/modules/ve/dm/ve.dm.TransactionProcessor.js +++ b/modules/ve/dm/ve.dm.TransactionProcessor.js @@ -27,6 +27,7 @@ ve.dm.TransactionProcessor = function VeDmTransactionProcessor( doc, transaction // Linear model offset that we're currently at. Operations in the transaction are ordered, so // the cursor only ever moves forward. this.cursor = 0; + this.metadataCursor = 0; // Adjustment used to convert between linear model offsets in the original linear model and // in the half-updated linear model. this.adjustment = 0; @@ -101,6 +102,17 @@ ve.dm.TransactionProcessor.prototype.executeOperation = function ( op ) { } }; +/** + * Advance the main data cursor. + * + * @method + * @param {number} increment Number of positions to increment the cursor by + */ +ve.dm.TransactionProcessor.prototype.advanceCursor = function ( increment ) { + this.cursor += increment; + this.metadataCursor = 0; +}; + /** * Process all operations. * @@ -286,7 +298,7 @@ ve.dm.TransactionProcessor.prototype.applyChangeMarkers = function () { /** * Execute a retain operation. * - * This method is called within the context of a document synchronizer instance. + * This method is called within the context of a transaction processor instance. * * This moves the cursor by op.length and applies annotations to the characters that the cursor * moved over. @@ -297,13 +309,28 @@ ve.dm.TransactionProcessor.prototype.applyChangeMarkers = function () { */ ve.dm.TransactionProcessor.processors.retain = function ( op ) { this.applyAnnotations( this.cursor + op.length ); - this.cursor += op.length; + this.advanceCursor( op.length ); +}; + +/** + * Execute a metadata retain operation. + * + * This method is called within the context of a transaction processor instance. + * + * This moves the metadata cursor by op.length. + * + * @method + * @param {Object} op Operation object: + * @param {number} op.length Number of elements to retain + */ +ve.dm.TransactionProcessor.processors.retainMetadata = function ( op ) { + this.metadataCursor += op.length; }; /** * Execute an annotate operation. * - * This method is called within the context of a document synchronizer instance. + * This method is called within the context of a transaction processor instance. * * This will add an annotation to or remove an annotation from `this.set`or `this.clear`. * @@ -334,7 +361,7 @@ ve.dm.TransactionProcessor.processors.annotate = function ( op ) { /** * Execute an attribute operation. * - * This method is called within the context of a document synchronizer instance. + * This method is called within the context of a transaction processor instance. * * This sets the attribute named `op.key` on the element at `this.cursor` to `op.to`, or unsets it if * `op.to === undefined`. `op.from `is not checked against the old value, but is used instead of `op.to` @@ -379,7 +406,7 @@ ve.dm.TransactionProcessor.processors.attribute = function ( op ) { /** * Execute a replace operation. * - * This method is called within the context of a document synchronizer instance. + * This method is called within the context of a transaction processor instance. * * This replaces a range of linear model data with another at this.cursor, figures out how the model * tree needs to be synchronized, and queues this in the DocumentSynchronizer. @@ -457,7 +484,7 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) { this.setChangeMarker( parentOffset + this.adjustment, 'content' ); } // Advance the cursor - this.cursor += insert.length; + this.advanceCursor( insert.length ); this.adjustment += insert.length - remove.length; } else { // Structural replacement @@ -477,7 +504,7 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) { this.cursor - this.adjustment + opRemove.length ) ); prevCursor = this.cursor; - this.cursor += opInsert.length; + this.advanceCursor( opInsert.length ); // Paint the removed selection, figure out which nodes were // covered, and add their ranges to the affected ranges list if ( opRemove.length > 0 ) { @@ -584,3 +611,20 @@ ve.dm.TransactionProcessor.processors.replace = function ( op ) { ); } }; + +/** + * Execute a metadata replace operation. + * + * This method is called within the context of a transaction processor instance. + * + * @method + * @param {Object} op Operation object + * @param {Array} op.remove Metadata to remove + * @param {Array} op.insert Metadata to insert + */ +ve.dm.TransactionProcessor.processors.replaceMetadata = function ( op ) { + var remove = this.reversed ? op.insert : op.remove, + insert = this.reversed ? op.remove : op.insert; + + this.document.spliceMetadata( this.cursor, this.metadataCursor, remove.length, insert ); +}; diff --git a/modules/ve/test/dm/ve.dm.Transaction.test.js b/modules/ve/test/dm/ve.dm.Transaction.test.js index 55d7f0fe1a..25202b3372 100644 --- a/modules/ve/test/dm/ve.dm.Transaction.test.js +++ b/modules/ve/test/dm/ve.dm.Transaction.test.js @@ -1115,3 +1115,122 @@ QUnit.test( 'push*Annotating', 8, function ( assert ) { }; runBuilderTests( assert, cases ); } ); + +QUnit.test( 'newFromMetadataInsertion', 2, function( assert ) { + var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ), + element = { + 'type': 'metaInline', + 'attributes': { + 'style': 'comment', + 'text': ' inline ' + } + }, + cases = { + 'inserting metadata element into existing element list': { + 'args': [ doc, 11, 2, [ element ] ], + 'ops': [ + { 'type': 'retain', 'length': 11 }, + { 'type': 'retainMetadata', 'length': 2 }, + { + 'type': 'replaceMetadata', + 'remove': [], + 'insert': [ element ] + }, + { 'type': 'retainMetadata', 'length': 2 }, + { 'type': 'retain', 'length': 1 }, + ] + }, + 'inserting metadata element into empty list': { + 'args': [ doc, 3, 0, [ element ] ], + 'ops': [ + { 'type': 'retain', 'length': 3 }, + { + 'type': 'replaceMetadata', + 'remove': [], + 'insert': [ element ] + }, + { 'type': 'retain', 'length': 9 }, + ] + } + }; + runConstructorTests( assert, ve.dm.Transaction.newFromMetadataInsertion, cases ); +} ); + +QUnit.test( 'newFromMetadataRemoval', 4, function( assert ) { + var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ), + allElements = ve.dm.example.withMetaMetaData[11], + someElements = allElements.slice( 1, 3 ), + cases = { + 'removing all metadata elements from metadata list': { + 'args': [ doc, 11, new ve.Range( 0, 4 ) ], + 'ops': [ + { 'type': 'retain', 'length': 11 }, + { + 'type': 'replaceMetadata', + 'remove': allElements, + 'insert': [] + }, + { 'type': 'retain', 'length': 1 }, + ] + }, + 'removing some metadata elements from metadata list': { + 'args': [ doc, 11, new ve.Range( 1, 3 ) ], + 'ops': [ + { 'type': 'retain', 'length': 11 }, + { 'type': 'retainMetadata', 'length': 1 }, + { + 'type': 'replaceMetadata', + 'remove': someElements, + 'insert': [] + }, + { 'type': 'retainMetadata', 'length': 1 }, + { 'type': 'retain', 'length': 1 }, + ] + }, + 'checks metadata at offset is non-empty': { + 'args': [ doc, 5, new ve.Range( 1, 3 ) ], + 'exception': Error + }, + 'checks range is valid for metadata at offset': { + 'args': [ doc, 11, new ve.Range( 1, 5 ) ], + 'exception': Error + } + }; + runConstructorTests( assert, ve.dm.Transaction.newFromMetadataRemoval, cases ); +} ); + +QUnit.test( 'newFromMetadataElementReplacement', 3, function( assert ) { + var doc = new ve.dm.Document( ve.copyArray( ve.dm.example.withMeta ) ), + newElement = { + 'type': 'metaInline', + 'attributes': { + 'style': 'comment', + 'text': ' inline ' + } + }, + oldElement = ve.dm.example.withMetaMetaData[11][3], + cases = { + 'replacing metadata at end of list': { + 'args': [ doc, 11, 3, newElement ], + 'ops': [ + { 'type': 'retain', 'length': 11 }, + { 'type': 'retainMetadata', 'length': 3 }, + { + 'type': 'replaceMetadata', + 'remove': [ oldElement ], + 'insert': [ newElement ] + }, + { 'type': 'retain', 'length': 1 }, + ] + }, + 'checks offset is in bounds': { + 'args': [ doc, 15, 0, newElement ], + 'exception': Error + }, + 'checks metadata index is in bounds': { + 'args': [ doc, 11, 5, newElement ], + 'exception': Error + } + }; + runConstructorTests( assert, ve.dm.Transaction.newFromMetadataElementReplacement, cases ); +} ); diff --git a/modules/ve/test/dm/ve.dm.TransactionProcessor.test.js b/modules/ve/test/dm/ve.dm.TransactionProcessor.test.js index be1028c047..4fc0c0debe 100644 --- a/modules/ve/test/dm/ve.dm.TransactionProcessor.test.js +++ b/modules/ve/test/dm/ve.dm.TransactionProcessor.test.js @@ -40,12 +40,20 @@ QUnit.test( 'protection against double application', 3, function ( assert ) { ); } ); -QUnit.test( 'commit/rollback', 66, function ( assert ) { +QUnit.test( 'commit/rollback', 86, function ( assert ) { var i, key, originalData, originalDoc, msg, testDocument, tx, expectedData, expectedDocument, bold = ve.dm.example.createAnnotation( ve.dm.example.bold ), italic = ve.dm.example.createAnnotation( ve.dm.example.italic ), underline = ve.dm.example.createAnnotation( ve.dm.example.underline ), + metaElementInsert = { + 'type': 'metaInline', + 'attributes': { + 'style': 'comment', + 'text': ' inline ' + } + }, + metaElementInsertClose = { 'type': '/metaInline' }, cases = { 'no operations': { 'calls': [], @@ -300,6 +308,66 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) { data.splice( 4, 0, 'b' ); ve.setProp( data[0], 'internal', 'changed', 'content', 1 ); } + }, + 'inserting metadata element into existing element list': { + 'data': ve.dm.example.withMeta, + 'calls': [ + ['pushRetain', 11 ], + ['pushRetainMetadata', 2 ], + ['pushReplaceMetadata', [], [ metaElementInsert ] ], + ['pushRetainMetadata', 2 ], + ['pushRetain', 1 ], + ], + 'expected': function( data ) { + data.splice( 25, 0, metaElementInsert, metaElementInsertClose ); + } + }, + 'inserting metadata element into empty list': { + 'data': ve.dm.example.withMeta, + 'calls': [ + ['pushRetain', 3 ], + ['pushReplaceMetadata', [], [ metaElementInsert ] ], + ['pushRetain', 9 ], + ], + 'expected': function( data ) { + data.splice( 7, 0, metaElementInsert, metaElementInsertClose ); + } + }, + 'removing all metadata elements from a metadata list': { + 'data': ve.dm.example.withMeta, + 'calls': [ + ['pushRetain', 11 ], + ['pushReplaceMetadata', ve.dm.example.withMetaMetaData[11], [] ], + ['pushRetain', 1 ], + ], + 'expected': function( data ) { + data.splice( 21, 8 ); + } + }, + 'removing some metadata elements from metadata list': { + 'data': ve.dm.example.withMeta, + 'calls': [ + ['pushRetain', 11 ], + ['pushRetainMetadata', 1 ], + ['pushReplaceMetadata', ve.dm.example.withMetaMetaData[11].slice( 1, 3 ), [] ], + ['pushRetainMetadata', 1 ], + ['pushRetain', 1 ], + ], + 'expected': function( data ) { + data.splice( 23, 4 ); + } + }, + 'replacing metadata at end of list': { + 'data': ve.dm.example.withMeta, + 'calls': [ + ['pushRetain', 11 ], + ['pushRetainMetadata', 3 ], + ['pushReplaceMetadata', [ ve.dm.example.withMetaMetaData[11][3] ], [ metaElementInsert ] ], + ['pushRetain', 1 ], + ], + 'expected': function( data ) { + data.splice( 27, 2, metaElementInsert, metaElementInsertClose ); + } } }; @@ -321,15 +389,15 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) { if ( 'expected' in cases[msg] ) { // Generate original document originalData = cases[msg].data || ve.dm.example.data; - originalDoc = new ve.dm.Document( originalData ); + originalDoc = new ve.dm.Document( ve.copyArray( originalData ) ); testDocument = new ve.dm.Document( ve.copyArray( originalData ) ); // Generate expected document expectedData = ve.copyArray( originalData ); cases[msg].expected( expectedData ); - expectedDocument = new ve.dm.Document( expectedData ); + expectedDocument = new ve.dm.Document( ve.copyArray ( expectedData ) ); // Commit ve.dm.TransactionProcessor.commit( testDocument, tx ); - assert.deepEqual( testDocument.getData(), expectedData, 'commit (data): ' + msg ); + assert.deepEqual( testDocument.getFullData(), expectedData, 'commit (data): ' + msg ); assert.equalNodeTree( testDocument.getDocumentNode(), expectedDocument.getDocumentNode(), @@ -337,7 +405,7 @@ QUnit.test( 'commit/rollback', 66, function ( assert ) { ); // Rollback ve.dm.TransactionProcessor.rollback( testDocument, tx ); - assert.deepEqual( testDocument.getData(), originalData, 'rollback (data): ' + msg ); + assert.deepEqual( testDocument.getFullData(), originalData, 'rollback (data): ' + msg ); assert.equalNodeTree( testDocument.getDocumentNode(), originalDoc.getDocumentNode(),